Add POV and character identity foundation
This commit is contained in:
parent
1f5ab018b0
commit
caa70be9f4
@ -138,6 +138,11 @@ var tests = new (string Name, Action Test)[]
|
||||
("Story Intelligence resumed chapters use canonical labels", StoryIntelligenceResumedChaptersUseCanonicalLabels),
|
||||
("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch),
|
||||
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
|
||||
("Structural POV rides existing chapter analysis", StructuralPovRidesExistingChapterAnalysis),
|
||||
("Structural POV persists through import resolution", StructuralPovPersistsThroughImportResolution),
|
||||
("Normal scene and chapter saves persist POV", NormalSceneAndChapterSavesPersistPov),
|
||||
("Character merge is a reusable stored procedure workflow", CharacterMergeIsReusableStoredProcedureWorkflow),
|
||||
("Story Intelligence character review suppresses resolved narrator POV", StoryIntelligenceCharacterReviewSuppressesResolvedNarratorPov),
|
||||
("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),
|
||||
@ -3355,6 +3360,94 @@ static void StoryIntelligenceCharacterDefaultChangesPreservePersistedResults()
|
||||
Assert(!service.Contains("QueueAdminTextAsync", StringComparison.Ordinal), "Character review defaults must not queue new Story Intelligence work.");
|
||||
}
|
||||
|
||||
static void StructuralPovRidesExistingChapterAnalysis()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Chapter-Structure-Prompt-V2.md"));
|
||||
var models = File.ReadAllText(Path.Combine(root, "Models/ChapterStructureModels.cs"));
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs"));
|
||||
var normaliser = File.ReadAllText(Path.Combine(root, "Services/ChapterStructureBoundaryNormaliser.cs"));
|
||||
|
||||
Assert(prompt.Contains("defaultPov", StringComparison.Ordinal), "Structural prompt should ask for default POV in the existing chapter pass.");
|
||||
Assert(prompt.Contains("povOverride", StringComparison.Ordinal), "Structural prompt should allow scene-level POV overrides.");
|
||||
Assert(prompt.Contains("Narrated by Maggie", StringComparison.Ordinal), "Structural prompt should treat explicit narrator labels as strong evidence.");
|
||||
Assert(prompt.Contains("knownCharacters", StringComparison.Ordinal), "Structural prompt should prefer supplied canonical characters.");
|
||||
Assert(models.Contains("ChapterStructurePovSuggestion", StringComparison.Ordinal), "Structural response model should carry compact POV suggestions.");
|
||||
Assert(service.Contains("KnownCharacters { get; init; } = []", StringComparison.Ordinal), "Structural analysis request should carry known character context.");
|
||||
Assert(service.Contains("SuggestedPovName", StringComparison.Ordinal), "Structural analysis should copy POV suggestions to scene boundaries.");
|
||||
Assert(normaliser.Contains("PovOverride = boundary.PovOverride", StringComparison.Ordinal), "Boundary repair should preserve scene POV overrides.");
|
||||
Assert(normaliser.Contains("DefaultPov = chapterStructure.DefaultPov", StringComparison.Ordinal), "Boundary repair should preserve chapter default POV.");
|
||||
}
|
||||
|
||||
static void StructuralPovPersistsThroughImportResolution()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
|
||||
var migration = File.ReadAllText(Path.Combine(root, "Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql"));
|
||||
|
||||
var mapping = onboarding.IndexOf("await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID)", StringComparison.Ordinal);
|
||||
var pov = onboarding.IndexOf("await ApplyStructuralPovAsync(preview, structuralScenes, result)", StringComparison.Ordinal);
|
||||
Assert(mapping >= 0 && pov > mapping, "Structural POV should resolve after deterministic canonical character mapping.");
|
||||
Assert(onboarding.Contains("BuildCanonicalCharacterNameIndexAsync", StringComparison.Ordinal), "Import POV resolution should reuse canonical character names and aliases.");
|
||||
Assert(onboarding.Contains("characters.ListAliasesAsync", StringComparison.Ordinal), "Import POV resolution should include accepted aliases.");
|
||||
Assert(onboarding.Contains("\"StructuralInherited\"", StringComparison.Ordinal), "Inherited scene POV should carry provenance.");
|
||||
Assert(onboarding.Contains("\"StructuralOverride\"", StringComparison.Ordinal), "Scene POV overrides should carry provenance.");
|
||||
Assert(migration.Contains("POVSuggestedName", StringComparison.Ordinal), "Unresolved structural POV names should survive as suggested names.");
|
||||
Assert(migration.Contains("@OverwriteAuthor bit = 0", StringComparison.Ordinal), "Structural POV updates should not overwrite author POV by default.");
|
||||
}
|
||||
|
||||
static void NormalSceneAndChapterSavesPersistPov()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
|
||||
var services = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
|
||||
var chaptersEdit = File.ReadAllText(Path.Combine(root, "Views/Chapters/Edit.cshtml"));
|
||||
var chaptersDetails = File.ReadAllText(Path.Combine(root, "Views/Chapters/Details.cshtml"));
|
||||
var migration = File.ReadAllText(Path.Combine(root, "Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql"));
|
||||
|
||||
Assert(repository.Contains("chapter.POVCharacterID", StringComparison.Ordinal), "Chapter repository should pass POVCharacterID to Chapter_Save.");
|
||||
Assert(repository.Contains("scene.POVCharacterID", StringComparison.Ordinal), "Scene repository should pass POVCharacterID to Scene_Save.");
|
||||
Assert(services.Contains("POVCharacterID = model.POVCharacterID", StringComparison.Ordinal), "Normal scene/chapter services should persist submitted POV values.");
|
||||
Assert(chaptersEdit.Contains("asp-for=\"POVCharacterID\"", StringComparison.Ordinal), "Chapter edit should expose a compact POV selector.");
|
||||
Assert(chaptersDetails.Contains("Apply to scenes", StringComparison.Ordinal), "Chapter details should allow POV propagation to scenes.");
|
||||
Assert(chaptersDetails.Contains("POV: @chapterPov", StringComparison.Ordinal), "Chapter details should display compact POV status.");
|
||||
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Scene_Save", StringComparison.Ordinal), "New migration should own the Scene_Save parameter update.");
|
||||
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Chapter_Save", StringComparison.Ordinal), "New migration should own the Chapter_Save parameter update.");
|
||||
}
|
||||
|
||||
static void CharacterMergeIsReusableStoredProcedureWorkflow()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
|
||||
var services = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
|
||||
var controller = File.ReadAllText(Path.Combine(root, "Controllers/CharactersController.cs"));
|
||||
var details = File.ReadAllText(Path.Combine(root, "Views/Characters/Details.cshtml"));
|
||||
var merge = File.ReadAllText(Path.Combine(root, "Views/Characters/Merge.cshtml"));
|
||||
var siCharacters = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
|
||||
var migration = File.ReadAllText(Path.Combine(root, "Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql"));
|
||||
|
||||
Assert(repository.Contains("Task<CharacterMergeResult?> MergeAsync", StringComparison.Ordinal), "Character repository should expose a reusable merge operation.");
|
||||
Assert(services.Contains("Task<CharacterMergeViewModel?> GetMergeAsync", StringComparison.Ordinal), "Character service should expose merge UI data.");
|
||||
Assert(controller.Contains("public async Task<IActionResult> Merge(int id)", StringComparison.Ordinal), "Normal Character UI should expose merge GET.");
|
||||
Assert(controller.Contains("public async Task<IActionResult> Merge(CharacterMergeForm form)", StringComparison.Ordinal), "Normal Character UI should expose merge POST.");
|
||||
Assert(details.Contains("Merge with another character", StringComparison.Ordinal), "Character details should link to merge.");
|
||||
Assert(merge.Contains("References, aliases, scene appearances, POV links, relationship events and knowledge are moved", StringComparison.Ordinal), "Merge UI should explain reference propagation.");
|
||||
Assert(siCharacters.Contains("characters.MergeAsync(candidate.ExistingCharacterID.Value, targetCharacterId.Value", StringComparison.Ordinal), "Story Intelligence aliasing should reuse canonical merge when an existing duplicate is known.");
|
||||
Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Character_Merge", StringComparison.Ordinal), "Character merge should be database-backed.");
|
||||
Assert(migration.Contains("UPDATE dbo.RelationshipEvents", StringComparison.Ordinal), "Merge should move relationship events when relationships converge.");
|
||||
Assert(migration.Contains("Archived during character merge to avoid a self-relationship", StringComparison.Ordinal), "Merge should archive self-relationships instead of flattening them.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceCharacterReviewSuppressesResolvedNarratorPov()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
|
||||
|
||||
Assert(service.Contains("!importedScene.POVCharacterID.HasValue", StringComparison.Ordinal), "Scene Intelligence POV candidates should not be reintroduced when canonical scene POV already exists.");
|
||||
Assert(service.Contains("IsResolvedPovReference(cleanName, importedScene)", StringComparison.Ordinal), "Narrator-style observed names should be suppressed when scene POV is resolved.");
|
||||
Assert(service.Contains("IgnoredNames.Contains(Normalise(name))", StringComparison.Ordinal), "Suppression should reuse existing generic/contextual reference filtering.");
|
||||
}
|
||||
|
||||
static void StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases()
|
||||
{
|
||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||
|
||||
@ -51,6 +51,7 @@ public sealed class ChaptersController(IChapterService chapters) : Controller
|
||||
|
||||
createModel.ChapterNumber = model.ChapterNumber;
|
||||
createModel.ChapterTitle = model.ChapterTitle;
|
||||
createModel.POVCharacterID = model.POVCharacterID;
|
||||
createModel.Summary = model.Summary;
|
||||
createModel.RevisionStatusID = model.RevisionStatusID;
|
||||
return View("Edit", createModel);
|
||||
@ -59,6 +60,15 @@ public sealed class ChaptersController(IChapterService chapters) : Controller
|
||||
return RedirectToAction(nameof(Details), new { id = chapterId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SetScenesPov(int chapterId, int? povCharacterId, bool overwriteAuthor)
|
||||
{
|
||||
var updated = await chapters.SetScenesPovAsync(chapterId, povCharacterId, overwriteAuthor);
|
||||
TempData["ChapterMessage"] = $"POV updated on {updated:N0} scene{(updated == 1 ? string.Empty : "s")}.";
|
||||
return RedirectToAction(nameof(Details), new { id = chapterId });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Archive(int id, int bookId)
|
||||
|
||||
@ -32,6 +32,12 @@ public sealed class CharactersController(ICharacterService characters) : Control
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Merge(int id)
|
||||
{
|
||||
var model = await characters.GetMergeAsync(id);
|
||||
return model is null ? NotFound() : View(model);
|
||||
}
|
||||
|
||||
public async Task<IActionResult> CropAvatar(int imageId)
|
||||
{
|
||||
var model = await characters.GetAvatarCropAsync(imageId);
|
||||
@ -99,6 +105,35 @@ public sealed class CharactersController(ICharacterService characters) : Control
|
||||
: RedirectToAction(nameof(Details), new { id = saveResult.CharacterID });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Merge(CharacterMergeForm form)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await characters.MergeAsync(form);
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
TempData["CharacterMessage"] = $"{result.SourceCharacterName} merged into {result.TargetCharacterName}.";
|
||||
return RedirectToAction(nameof(Details), new { id = result.TargetCharacterID });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, ex.Message);
|
||||
var model = await characters.GetMergeAsync(form.SourceCharacterID);
|
||||
if (model is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
model.Form = form;
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateSexWorkflow(CharacterEditViewModel model)
|
||||
{
|
||||
if (model.SexValueID == CharacterEditViewModel.CustomSexValue)
|
||||
|
||||
@ -75,6 +75,8 @@ public interface IChapterRepository
|
||||
Task<IReadOnlyList<Chapter>> ListByBookAsync(int bookId);
|
||||
Task<Chapter?> GetAsync(int chapterId);
|
||||
Task<int> SaveAsync(Chapter chapter);
|
||||
Task UpdatePovCharacterAsync(int chapterId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null);
|
||||
Task<int> SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor);
|
||||
Task ArchiveAsync(int chapterId);
|
||||
}
|
||||
|
||||
@ -84,7 +86,7 @@ public interface ISceneRepository
|
||||
Task<IReadOnlyList<Scene>> ListByFloorPlanAsync(int floorPlanId);
|
||||
Task<Scene?> GetAsync(int sceneId);
|
||||
Task<int> SaveAsync(Scene scene);
|
||||
Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId);
|
||||
Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false);
|
||||
Task SaveFloorPlanLinkAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId);
|
||||
Task SavePurposesAsync(int sceneId, IEnumerable<int> purposeIds);
|
||||
Task SaveMetricValuesAsync(int sceneId, IEnumerable<SceneMetricValue> metricValues);
|
||||
@ -216,6 +218,7 @@ public interface ICharacterRepository
|
||||
Task<int> AddAliasAsync(int characterId, string alias, int? sortOrder);
|
||||
Task UpdateAliasAsync(int characterAliasId, string alias, int? sortOrder);
|
||||
Task DeleteAliasAsync(int characterAliasId);
|
||||
Task<CharacterMergeResult?> MergeAsync(int sourceCharacterId, int targetCharacterId, int? userId);
|
||||
Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId);
|
||||
Task<CharacterSexValue?> GetSexValueAsync(int characterSexValueId, int ownerUserId);
|
||||
Task<int> GetOrCreateSexValueAsync(int ownerUserId, string sexName);
|
||||
@ -2462,7 +2465,33 @@ public sealed class ChapterRepository(ISqlConnectionFactory connectionFactory) :
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.Chapter_Save",
|
||||
new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID },
|
||||
new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID, chapter.POVCharacterID },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task UpdatePovCharacterAsync(int chapterId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.Chapter_UpdatePovCharacter",
|
||||
new
|
||||
{
|
||||
ChapterID = chapterId,
|
||||
POVCharacterID = povCharacterId,
|
||||
POVSource = source,
|
||||
POVConfidence = confidence,
|
||||
POVEvidence = evidence,
|
||||
POVSuggestedName = suggestedName
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<int> SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<int>(
|
||||
"dbo.Chapter_SetScenesPov",
|
||||
new { ChapterID = chapterId, POVCharacterID = povCharacterId, OverwriteAuthor = overwriteAuthor },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
@ -2535,17 +2564,27 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I
|
||||
scene.RevisionStatusID,
|
||||
scene.PrimaryLocationID,
|
||||
scene.FloorPlanID,
|
||||
scene.InitialFloorPlanFloorID
|
||||
scene.InitialFloorPlanFloorID,
|
||||
scene.POVCharacterID
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId)
|
||||
public async Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.Scene_UpdatePovCharacter",
|
||||
new { SceneID = sceneId, POVCharacterID = povCharacterId },
|
||||
new
|
||||
{
|
||||
SceneID = sceneId,
|
||||
POVCharacterID = povCharacterId,
|
||||
POVSource = source,
|
||||
POVConfidence = confidence,
|
||||
POVEvidence = evidence,
|
||||
POVSuggestedName = suggestedName,
|
||||
OverwriteAuthor = overwriteAuthor
|
||||
},
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
@ -3696,6 +3735,22 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory)
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<CharacterMergeResult?> MergeAsync(int sourceCharacterId, int targetCharacterId, int? userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
try
|
||||
{
|
||||
return await connection.QuerySingleOrDefaultAsync<CharacterMergeResult>(
|
||||
"dbo.Character_Merge",
|
||||
new { SourceCharacterID = sourceCharacterId, TargetCharacterID = targetCharacterId, UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
catch (SqlException ex) when (ex.Number == 51000)
|
||||
{
|
||||
throw new InvalidOperationException(ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CharacterSexValue>> ListSexValuesAsync(int ownerUserId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
|
||||
@ -12,7 +12,7 @@ You are PlotDirector's Chapter Structure engine.
|
||||
|
||||
You are an assistant archivist for a novelist. You observe chapter structure only. You do not critique, rewrite, create story entities or infer canon.
|
||||
|
||||
Your only task is to identify likely scene boundaries in one chapter, provide a short descriptive title for each detected scene, and provide a light factual summary for each detected scene.
|
||||
Your only task is to identify likely scene boundaries in one chapter, provide a short descriptive title for each detected scene, provide a light factual summary for each detected scene, and record compact point-of-view evidence when it is clear.
|
||||
|
||||
## Operating Principles
|
||||
|
||||
@ -92,6 +92,21 @@ The final scene must end at the highest paragraph number supplied.
|
||||
|
||||
Do not create a separate final scene for a closing line unless it corresponds to an actual supplied paragraph number. If the final beat belongs to the previous scene, include it in the previous scene.
|
||||
|
||||
## Point of View Guidance
|
||||
|
||||
Identify the default chapter point of view only when the supplied text provides a clear narrator/viewpoint signal.
|
||||
|
||||
Explicit manuscript labels such as `Narrated by Maggie` are extremely strong evidence.
|
||||
When `chapterContext.knownCharacters` contains a matching canonical character or alias, prefer that spelling for POV names.
|
||||
|
||||
If no POV can be determined, return `defaultPov` as `null`.
|
||||
|
||||
If the chapter has one default POV with a few clear scene-level changes, set `mixedPov` to `true` and add `povOverride` only on scenes where the POV differs from the chapter default.
|
||||
|
||||
If the whole chapter is mixed and no default is clear, set `mixedPov` to `true`, return `defaultPov` as `null`, and add scene `povOverride` values only where clear.
|
||||
|
||||
Use manuscript names exactly as presented. Do not invent canonical full names.
|
||||
|
||||
Do not create placeholder scenes or invalid ranges to "ensure coverage". If the previous scene already ends at the final supplied paragraph, stop there.
|
||||
|
||||
Scene boundaries must:
|
||||
@ -113,6 +128,8 @@ Return only these root properties:
|
||||
|
||||
- `schemaVersion`
|
||||
- `chapterSummary`
|
||||
- `defaultPov`
|
||||
- `mixedPov`
|
||||
- `sceneBoundaries`
|
||||
|
||||
Each `sceneBoundaries` item must contain exactly:
|
||||
@ -122,6 +139,7 @@ Each `sceneBoundaries` item must contain exactly:
|
||||
- `endParagraph`
|
||||
- `suggestedTitle`
|
||||
- `structuralSummary`
|
||||
- `povOverride`
|
||||
- `confidence`
|
||||
- `reason`
|
||||
|
||||
@ -134,6 +152,10 @@ Use `structuralSummary` for a factual, neutral summary of what happens in that s
|
||||
|
||||
Use `suggestedTitle` for a concise, factual scene title. It must be 2 to 6 words, memorable without being dramatic, and grounded only in the supplied text. Avoid generic titles such as "Opening Scene", "Conversation", "Chapter Scene", or "Scene 1". Do not truncate or restate the structural summary.
|
||||
|
||||
Use `defaultPov` for the chapter's suggested default narrator/viewpoint character, or `null` when not clear. When present it must contain exactly `characterName`, `confidence`, `evidence`, and `isExplicit`.
|
||||
|
||||
Use `povOverride` only where that scene clearly differs from `defaultPov`, or where there is no default but that scene's POV is clear. Otherwise return `null`.
|
||||
|
||||
Use short factual reasons, such as:
|
||||
|
||||
`Location changes from house to train.`
|
||||
@ -156,6 +178,13 @@ Do not output:
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"chapterSummary": "Structural analysis of the supplied chapter.",
|
||||
"defaultPov": {
|
||||
"characterName": "Maggie",
|
||||
"confidence": 0.96,
|
||||
"evidence": "The chapter begins with an explicit Narrated by Maggie label.",
|
||||
"isExplicit": true
|
||||
},
|
||||
"mixedPov": false,
|
||||
"sceneBoundaries": [
|
||||
{
|
||||
"sceneNumber": 1,
|
||||
@ -163,6 +192,7 @@ Do not output:
|
||||
"endParagraph": 3,
|
||||
"suggestedTitle": "Arrival at the Church",
|
||||
"structuralSummary": "The opening scene follows the same continuous action across the supplied paragraphs, keeping the characters in one time and place while the immediate situation is established.",
|
||||
"povOverride": null,
|
||||
"confidence": 0.86,
|
||||
"reason": "Opening scene remains in the same place and time."
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ public sealed class ChapterStructureModel
|
||||
{
|
||||
public string? SchemaVersion { get; init; }
|
||||
public string? ChapterSummary { get; init; }
|
||||
public ChapterStructurePovSuggestion? DefaultPov { get; init; }
|
||||
public bool? MixedPov { get; init; }
|
||||
public List<ChapterSceneBoundary>? SceneBoundaries { get; init; }
|
||||
|
||||
[JsonExtensionData]
|
||||
@ -20,9 +22,18 @@ public sealed class ChapterSceneBoundary
|
||||
public int? EndParagraph { get; init; }
|
||||
public string? SuggestedTitle { get; init; }
|
||||
public string? StructuralSummary { get; init; }
|
||||
public ChapterStructurePovSuggestion? PovOverride { get; init; }
|
||||
public decimal? Confidence { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, JsonElement>? ExtensionData { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ChapterStructurePovSuggestion
|
||||
{
|
||||
public string? CharacterName { get; init; }
|
||||
public decimal? Confidence { get; init; }
|
||||
public string? Evidence { get; init; }
|
||||
public bool? IsExplicit { get; init; }
|
||||
}
|
||||
|
||||
@ -393,6 +393,11 @@ public sealed class Chapter
|
||||
public decimal ChapterNumber { get; set; }
|
||||
public string ChapterTitle { get; set; } = string.Empty;
|
||||
public int? POVCharacterID { get; set; }
|
||||
public string? POVCharacterName { get; set; }
|
||||
public string? POVSource { get; set; }
|
||||
public decimal? POVConfidence { get; set; }
|
||||
public string? POVEvidence { get; set; }
|
||||
public string? POVSuggestedName { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public int? ChapterPurposeID { get; set; }
|
||||
public string? ChapterPurposeName { get; set; }
|
||||
@ -429,6 +434,11 @@ public sealed class Scene
|
||||
public string? Summary { get; set; }
|
||||
public string? StructuralSummary { get; set; }
|
||||
public int? POVCharacterID { get; set; }
|
||||
public string? POVCharacterName { get; set; }
|
||||
public string? POVSource { get; set; }
|
||||
public decimal? POVConfidence { get; set; }
|
||||
public string? POVEvidence { get; set; }
|
||||
public string? POVSuggestedName { get; set; }
|
||||
public int? PrimaryLocationID { get; set; }
|
||||
public int? FloorPlanID { get; set; }
|
||||
public int? InitialFloorPlanFloorID { get; set; }
|
||||
@ -1001,6 +1011,14 @@ public sealed class Character
|
||||
public string? ArchivedReason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CharacterMergeResult
|
||||
{
|
||||
public int SourceCharacterID { get; set; }
|
||||
public int TargetCharacterID { get; set; }
|
||||
public string SourceCharacterName { get; set; } = string.Empty;
|
||||
public string TargetCharacterName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class CharacterSexValue
|
||||
{
|
||||
public int CharacterSexValueID { get; set; }
|
||||
|
||||
@ -91,6 +91,8 @@ public static class ChapterStructureBoundaryNormaliser
|
||||
{
|
||||
chapterStructure.SchemaVersion,
|
||||
chapterStructure.ChapterSummary,
|
||||
chapterStructure.DefaultPov,
|
||||
chapterStructure.MixedPov,
|
||||
chapterStructure.SceneBoundaries,
|
||||
Normalisation = normalisation.Issues.Select(issue => new
|
||||
{
|
||||
@ -111,6 +113,7 @@ public static class ChapterStructureBoundaryNormaliser
|
||||
EndParagraph = boundary.EndParagraph,
|
||||
SuggestedTitle = boundary.SuggestedTitle,
|
||||
StructuralSummary = boundary.StructuralSummary,
|
||||
PovOverride = boundary.PovOverride,
|
||||
Confidence = boundary.Confidence,
|
||||
Reason = boundary.Reason,
|
||||
ExtensionData = boundary.ExtensionData
|
||||
@ -202,6 +205,7 @@ public static class ChapterStructureBoundaryNormaliser
|
||||
EndParagraph = end,
|
||||
SuggestedTitle = boundary.SuggestedTitle,
|
||||
StructuralSummary = boundary.StructuralSummary,
|
||||
PovOverride = boundary.PovOverride,
|
||||
Confidence = boundary.Confidence,
|
||||
Reason = boundary.Reason,
|
||||
ExtensionData = boundary.ExtensionData
|
||||
@ -226,6 +230,7 @@ public static class ChapterStructureBoundaryNormaliser
|
||||
EndParagraph = paragraphCount,
|
||||
SuggestedTitle = final.SuggestedTitle,
|
||||
StructuralSummary = final.StructuralSummary,
|
||||
PovOverride = final.PovOverride,
|
||||
Confidence = final.Confidence,
|
||||
Reason = final.Reason,
|
||||
ExtensionData = final.ExtensionData
|
||||
@ -242,6 +247,8 @@ public static class ChapterStructureBoundaryNormaliser
|
||||
{
|
||||
SchemaVersion = chapterStructure.SchemaVersion,
|
||||
ChapterSummary = chapterStructure.ChapterSummary,
|
||||
DefaultPov = chapterStructure.DefaultPov,
|
||||
MixedPov = chapterStructure.MixedPov,
|
||||
SceneBoundaries = repaired,
|
||||
ExtensionData = chapterStructure.ExtensionData
|
||||
}, issues);
|
||||
|
||||
@ -114,6 +114,7 @@ public interface IChapterService
|
||||
Task<ChapterEditViewModel?> GetCreateAsync(int bookId);
|
||||
Task<ChapterEditViewModel?> GetEditAsync(int chapterId);
|
||||
Task<int> SaveAsync(ChapterEditViewModel model);
|
||||
Task<int> SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor);
|
||||
Task ArchiveAsync(int chapterId);
|
||||
}
|
||||
|
||||
@ -122,7 +123,7 @@ public interface ISceneService
|
||||
Task<SceneEditViewModel?> GetCreateAsync(int chapterId);
|
||||
Task<SceneEditViewModel?> GetEditAsync(int sceneId);
|
||||
Task<int> SaveAsync(SceneEditViewModel model);
|
||||
Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId);
|
||||
Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false);
|
||||
Task ArchiveAsync(int sceneId);
|
||||
Task MoveAsync(int sceneId, string direction);
|
||||
Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position);
|
||||
@ -261,8 +262,10 @@ public interface ICharacterService
|
||||
Task<CharacterEditViewModel?> GetCreateCharacterAsync(int projectId);
|
||||
Task<CharacterEditViewModel?> GetEditCharacterAsync(int characterId);
|
||||
Task<CharacterDetailViewModel?> GetCharacterDetailAsync(int characterId);
|
||||
Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId);
|
||||
Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId);
|
||||
Task<CharacterSaveResult> SaveCharacterAsync(CharacterEditViewModel model);
|
||||
Task<CharacterMergeResult?> MergeAsync(CharacterMergeForm form);
|
||||
Task<VisualIdentityImageResult> UploadCharacterImageAsync(CharacterImageUploadViewModel model);
|
||||
Task<VisualIdentityImageResult> SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model);
|
||||
Task RemoveCharacterAvatarAsync(int characterId);
|
||||
@ -1429,7 +1432,8 @@ public sealed class ChapterService(
|
||||
TextStatistics = manuscriptTextStatistics.Aggregate(sceneRows.Select(scene => scene.TextStatistics)),
|
||||
PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount),
|
||||
PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount),
|
||||
PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount)
|
||||
PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount),
|
||||
CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName)
|
||||
};
|
||||
}
|
||||
|
||||
@ -1452,7 +1456,8 @@ public sealed class ChapterService(
|
||||
ChapterNumber = existing.Count + 1,
|
||||
RevisionStatusID = DefaultRevisionStatusId(lookupData),
|
||||
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName),
|
||||
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName)
|
||||
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName),
|
||||
CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName)
|
||||
};
|
||||
}
|
||||
|
||||
@ -1478,6 +1483,7 @@ public sealed class ChapterService(
|
||||
BookID = chapter.BookID,
|
||||
ChapterNumber = chapter.ChapterNumber,
|
||||
ChapterTitle = chapter.ChapterTitle,
|
||||
POVCharacterID = chapter.POVCharacterID,
|
||||
Summary = chapter.Summary,
|
||||
ChapterPurposeID = chapter.ChapterPurposeID,
|
||||
RevisionStatusID = chapter.RevisionStatusID,
|
||||
@ -1485,7 +1491,8 @@ public sealed class ChapterService(
|
||||
Book = book,
|
||||
Project = project,
|
||||
RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName),
|
||||
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName)
|
||||
ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName),
|
||||
CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName)
|
||||
};
|
||||
}
|
||||
|
||||
@ -1503,6 +1510,7 @@ public sealed class ChapterService(
|
||||
BookID = model.BookID,
|
||||
ChapterNumber = model.ChapterNumber,
|
||||
ChapterTitle = model.ChapterTitle,
|
||||
POVCharacterID = model.POVCharacterID,
|
||||
Summary = model.Summary,
|
||||
ChapterPurposeID = model.ChapterPurposeID,
|
||||
RevisionStatusID = model.RevisionStatusID
|
||||
@ -1515,6 +1523,20 @@ public sealed class ChapterService(
|
||||
return chapterId;
|
||||
}
|
||||
|
||||
public async Task<int> SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor)
|
||||
{
|
||||
var chapter = await chapters.GetAsync(chapterId);
|
||||
var book = chapter is null ? null : await books.GetAsync(chapter.BookID);
|
||||
if (chapter is null || book is null)
|
||||
{
|
||||
throw new InvalidOperationException("Chapter could not be found.");
|
||||
}
|
||||
|
||||
var updated = await chapters.SetScenesPovAsync(chapterId, povCharacterId, overwriteAuthor);
|
||||
await activity.RecordAsync(book.ProjectID, "Updated", "Chapter", chapterId, chapter.ChapterTitle, $"Updated POV on {updated:N0} scene{(updated == 1 ? string.Empty : "s")}.");
|
||||
return updated;
|
||||
}
|
||||
|
||||
public async Task ArchiveAsync(int chapterId)
|
||||
{
|
||||
var chapter = await chapters.GetAsync(chapterId);
|
||||
@ -1669,6 +1691,7 @@ public sealed class SceneService(
|
||||
ScenePurposeNotes = model.ScenePurposeNotes,
|
||||
SceneOutcomeNotes = model.SceneOutcomeNotes,
|
||||
RevisionStatusID = model.RevisionStatusID,
|
||||
POVCharacterID = model.POVCharacterID,
|
||||
PrimaryLocationID = model.PrimaryLocationID,
|
||||
FloorPlanID = model.FloorPlanID,
|
||||
InitialFloorPlanFloorID = model.InitialFloorPlanFloorID
|
||||
@ -1690,8 +1713,8 @@ public sealed class SceneService(
|
||||
return sceneId;
|
||||
}
|
||||
|
||||
public Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId) =>
|
||||
scenes.UpdatePovCharacterAsync(sceneId, povCharacterId);
|
||||
public Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false) =>
|
||||
scenes.UpdatePovCharacterAsync(sceneId, povCharacterId, source, confidence, evidence, suggestedName, overwriteAuthor);
|
||||
|
||||
public async Task ArchiveAsync(int sceneId)
|
||||
{
|
||||
@ -9171,6 +9194,35 @@ public sealed class CharacterService(
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CharacterMergeViewModel?> GetMergeAsync(int sourceCharacterId)
|
||||
{
|
||||
var source = await characters.GetCharacterAsync(sourceCharacterId);
|
||||
if (source is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var project = await projects.GetAsync(source.ProjectID);
|
||||
if (project is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var targets = (await characters.ListCharactersAsync(source.ProjectID))
|
||||
.Where(character => character.CharacterID != source.CharacterID)
|
||||
.OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(character => new SelectListItem(character.CharacterName, character.CharacterID.ToString()))
|
||||
.ToList();
|
||||
|
||||
return new CharacterMergeViewModel
|
||||
{
|
||||
Project = project,
|
||||
SourceCharacter = source,
|
||||
Form = new CharacterMergeForm { SourceCharacterID = source.CharacterID },
|
||||
TargetOptions = targets
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<CharacterAvatarCropViewModel?> GetAvatarCropAsync(int characterImageId)
|
||||
{
|
||||
var image = await characters.GetCharacterImageAsync(characterImageId);
|
||||
@ -9246,6 +9298,26 @@ public sealed class CharacterService(
|
||||
return new CharacterSaveResult(characterId, uploadedCharacterImageId);
|
||||
}
|
||||
|
||||
public async Task<CharacterMergeResult?> MergeAsync(CharacterMergeForm form)
|
||||
{
|
||||
if (!form.Confirm)
|
||||
{
|
||||
throw new InvalidOperationException("Confirm the merge before continuing.");
|
||||
}
|
||||
|
||||
var result = await characters.MergeAsync(form.SourceCharacterID, form.TargetCharacterID, currentUser.UserId);
|
||||
if (result is not null)
|
||||
{
|
||||
var target = await characters.GetCharacterAsync(result.TargetCharacterID);
|
||||
if (target is not null)
|
||||
{
|
||||
await activity.RecordAsync(target.ProjectID, "Merged", "Character", result.TargetCharacterID, result.TargetCharacterName, $"{result.SourceCharacterName} was merged into {result.TargetCharacterName}.");
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable<string> postedAliases)
|
||||
{
|
||||
var aliases = AliasInput.Clean(postedAliases);
|
||||
|
||||
@ -136,6 +136,7 @@ public sealed class ManuscriptStructureAnalysisService(
|
||||
var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList();
|
||||
var sourceText = BuildSourceText(request.SourceParagraphs, startMap.WordParagraphIndex, endMap.WordParagraphIndex);
|
||||
var suggestedTitle = CleanSuggestedTitle(boundary.SuggestedTitle, sceneNumber);
|
||||
var pov = CleanPovSuggestion(boundary.PovOverride) ?? CleanPovSuggestion(chapter.DefaultPov);
|
||||
scenes.Add(new ManuscriptStructureSceneBoundary
|
||||
{
|
||||
TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}",
|
||||
@ -150,7 +151,11 @@ public sealed class ManuscriptStructureAnalysisService(
|
||||
WordEndPosition = endMap.WordParagraphIndex,
|
||||
StructuralSummary = Trim(boundary.StructuralSummary, 600),
|
||||
Reason = boundary.Reason,
|
||||
Confidence = boundary.Confidence
|
||||
Confidence = boundary.Confidence,
|
||||
SuggestedPovName = pov?.CharacterName,
|
||||
SuggestedPovConfidence = pov?.Confidence,
|
||||
SuggestedPovEvidence = pov?.Evidence,
|
||||
SuggestedPovIsOverride = boundary.PovOverride is not null && !string.IsNullOrWhiteSpace(boundary.PovOverride.CharacterName)
|
||||
});
|
||||
}
|
||||
|
||||
@ -161,7 +166,15 @@ public sealed class ManuscriptStructureAnalysisService(
|
||||
request.TemporaryChapterKey,
|
||||
scenes.Count);
|
||||
|
||||
return ManuscriptStructureAnalysisResult.Succeeded(request.TemporaryChapterKey, scenes, persistedJson);
|
||||
var defaultPov = CleanPovSuggestion(chapter.DefaultPov);
|
||||
return ManuscriptStructureAnalysisResult.Succeeded(
|
||||
request.TemporaryChapterKey,
|
||||
scenes,
|
||||
persistedJson,
|
||||
defaultPov?.CharacterName,
|
||||
defaultPov?.Confidence,
|
||||
defaultPov?.Evidence,
|
||||
chapter.MixedPov == true);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
@ -204,7 +217,8 @@ public sealed class ManuscriptStructureAnalysisService(
|
||||
chapterNumber = request.ChapterNumber,
|
||||
sourceLabel = request.SourceLabel,
|
||||
storyEra = request.StoryEra,
|
||||
primaryLocale = request.PrimaryLocale
|
||||
primaryLocale = request.PrimaryLocale,
|
||||
knownCharacters = request.KnownCharacters.Take(80)
|
||||
}, JsonOptions);
|
||||
|
||||
private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText)
|
||||
@ -444,6 +458,25 @@ public sealed class ManuscriptStructureAnalysisService(
|
||||
var wordCount = clean.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
|
||||
return wordCount is >= 2 and <= 6 ? clean : null;
|
||||
}
|
||||
|
||||
private static ChapterStructurePovSuggestion? CleanPovSuggestion(ChapterStructurePovSuggestion? suggestion)
|
||||
{
|
||||
var name = Trim(suggestion?.CharacterName, 120);
|
||||
if (string.IsNullOrWhiteSpace(name)
|
||||
|| string.Equals(name, "Unknown", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(name, "Mixed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ChapterStructurePovSuggestion
|
||||
{
|
||||
CharacterName = name,
|
||||
Confidence = suggestion?.Confidence,
|
||||
Evidence = Trim(suggestion?.Evidence, 400),
|
||||
IsExplicit = suggestion?.IsExplicit
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ManuscriptStructureAnalysisRequest
|
||||
@ -456,6 +489,7 @@ public sealed class ManuscriptStructureAnalysisRequest
|
||||
public string SourceLabel { get; init; } = string.Empty;
|
||||
public string? StoryEra { get; init; }
|
||||
public string? PrimaryLocale { get; init; }
|
||||
public IReadOnlyList<string> KnownCharacters { get; init; } = [];
|
||||
public string ChapterText { get; init; } = string.Empty;
|
||||
public IReadOnlyList<ManuscriptScanParagraphMap> ParagraphMap { get; init; } = [];
|
||||
public IReadOnlyList<ManuscriptScanSourceParagraph> SourceParagraphs { get; init; } = [];
|
||||
@ -467,10 +501,31 @@ public sealed class ManuscriptStructureAnalysisResult
|
||||
public bool Success { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public string? ParsedJson { get; init; }
|
||||
public string? DefaultPovName { get; init; }
|
||||
public decimal? DefaultPovConfidence { get; init; }
|
||||
public string? DefaultPovEvidence { get; init; }
|
||||
public bool MixedPov { get; init; }
|
||||
public IReadOnlyList<ManuscriptStructureSceneBoundary> Scenes { get; init; } = [];
|
||||
|
||||
public static ManuscriptStructureAnalysisResult Succeeded(string temporaryChapterKey, IReadOnlyList<ManuscriptStructureSceneBoundary> scenes, string? parsedJson)
|
||||
=> new() { TemporaryChapterKey = temporaryChapterKey, Success = true, Scenes = scenes, ParsedJson = parsedJson };
|
||||
public static ManuscriptStructureAnalysisResult Succeeded(
|
||||
string temporaryChapterKey,
|
||||
IReadOnlyList<ManuscriptStructureSceneBoundary> scenes,
|
||||
string? parsedJson,
|
||||
string? defaultPovName = null,
|
||||
decimal? defaultPovConfidence = null,
|
||||
string? defaultPovEvidence = null,
|
||||
bool mixedPov = false)
|
||||
=> new()
|
||||
{
|
||||
TemporaryChapterKey = temporaryChapterKey,
|
||||
Success = true,
|
||||
Scenes = scenes,
|
||||
ParsedJson = parsedJson,
|
||||
DefaultPovName = defaultPovName,
|
||||
DefaultPovConfidence = defaultPovConfidence,
|
||||
DefaultPovEvidence = defaultPovEvidence,
|
||||
MixedPov = mixedPov
|
||||
};
|
||||
|
||||
public static ManuscriptStructureAnalysisResult Failed(string temporaryChapterKey, string errorMessage, string? parsedJson = null)
|
||||
=> new() { TemporaryChapterKey = temporaryChapterKey, Success = false, ErrorMessage = errorMessage, ParsedJson = parsedJson };
|
||||
@ -492,4 +547,8 @@ public sealed class ManuscriptStructureSceneBoundary
|
||||
public string? StructuralSummary { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public decimal? Confidence { get; init; }
|
||||
public string? SuggestedPovName { get; init; }
|
||||
public decimal? SuggestedPovConfidence { get; init; }
|
||||
public string? SuggestedPovEvidence { get; init; }
|
||||
public bool SuggestedPovIsOverride { get; init; }
|
||||
}
|
||||
|
||||
@ -38,6 +38,8 @@ public sealed class OnboardingService(
|
||||
IWordCompanionPresenceService companionPresence,
|
||||
IManuscriptScanPreviewStore scanPreviews,
|
||||
ICharacterRepository characters,
|
||||
IChapterRepository chapters,
|
||||
ISceneRepository scenes,
|
||||
IOnboardingBuildRepository builds,
|
||||
IManuscriptStructureAnalysisService structureAnalysis,
|
||||
IManuscriptTextStatisticsService manuscriptTextStatistics,
|
||||
@ -273,6 +275,7 @@ public sealed class OnboardingService(
|
||||
await SaveStructuralChapterSourcesAsync(preview, existingBuild);
|
||||
await SaveSceneManuscriptSourcesAsync(preview, recoveredScenes, existingBuild);
|
||||
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
||||
await ApplyStructuralPovAsync(preview, recoveredScenes, existingBuild);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -357,6 +360,7 @@ public sealed class OnboardingService(
|
||||
await SaveStructuralChapterSourcesAsync(preview, result);
|
||||
await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result);
|
||||
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
||||
await ApplyStructuralPovAsync(preview, structuralScenes, result);
|
||||
var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
|
||||
var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(canonicalSceneSources);
|
||||
await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates);
|
||||
@ -374,6 +378,7 @@ public sealed class OnboardingService(
|
||||
{
|
||||
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();
|
||||
var structuralScenes = new List<ManuscriptStructureSceneBoundary>();
|
||||
var knownCharacters = await BuildKnownCharacterContextAsync(preview.ProjectID);
|
||||
for (var chapterIndex = 0; chapterIndex < chapterDecisions.Count; chapterIndex++)
|
||||
{
|
||||
var chapterDecision = chapterDecisions[chapterIndex];
|
||||
@ -406,6 +411,7 @@ public sealed class OnboardingService(
|
||||
SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}",
|
||||
StoryEra = BookContextOptions.StoryEraLabel(book.StoryEra),
|
||||
PrimaryLocale = BookContextOptions.PrimaryLocaleLabel(book.PrimaryLocaleCode),
|
||||
KnownCharacters = knownCharacters,
|
||||
ChapterText = chapterPreview.ChapterText,
|
||||
ParagraphMap = chapterPreview.ParagraphMap,
|
||||
SourceParagraphs = chapterPreview.SourceParagraphs
|
||||
@ -422,6 +428,120 @@ public sealed class OnboardingService(
|
||||
return structuralScenes;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> BuildKnownCharacterContextAsync(int projectId)
|
||||
{
|
||||
var names = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var character in await characters.ListCharactersAsync(projectId))
|
||||
{
|
||||
AddKnownCharacterName(names, character.CharacterName);
|
||||
AddKnownCharacterName(names, character.ShortName);
|
||||
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
|
||||
{
|
||||
AddKnownCharacterName(names, alias.Alias);
|
||||
}
|
||||
}
|
||||
|
||||
return names.ToList();
|
||||
}
|
||||
|
||||
private static void AddKnownCharacterName(ISet<string> names, string? name)
|
||||
{
|
||||
var clean = Clean(name);
|
||||
if (!string.IsNullOrWhiteSpace(clean))
|
||||
{
|
||||
names.Add(clean);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyStructuralPovAsync(
|
||||
ManuscriptScanPreview preview,
|
||||
IReadOnlyList<ManuscriptStructureSceneBoundary> structuralScenes,
|
||||
OnboardingManuscriptBuildResult result)
|
||||
{
|
||||
if (structuralScenes.Count == 0 || result.SceneMappings.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var characterIndex = await BuildCanonicalCharacterNameIndexAsync(preview.ProjectID);
|
||||
var chapterMap = result.ChapterMappings.ToDictionary(mapping => mapping.TemporaryChapterKey, StringComparer.Ordinal);
|
||||
var sceneMap = result.SceneMappings.ToDictionary(mapping => mapping.TemporarySceneKey, StringComparer.Ordinal);
|
||||
|
||||
foreach (var chapterScenes in structuralScenes.GroupBy(scene => scene.TemporaryChapterKey))
|
||||
{
|
||||
if (!chapterMap.TryGetValue(chapterScenes.Key, out var chapterMapping))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var defaultSuggestion = chapterScenes
|
||||
.Where(scene => !scene.SuggestedPovIsOverride && !string.IsNullOrWhiteSpace(scene.SuggestedPovName))
|
||||
.GroupBy(scene => Clean(scene.SuggestedPovName), StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(group => group.Count())
|
||||
.FirstOrDefault();
|
||||
if (defaultSuggestion is not null)
|
||||
{
|
||||
var sample = defaultSuggestion.First();
|
||||
characterIndex.TryGetValue(defaultSuggestion.Key, out var defaultCharacter);
|
||||
await chapters.UpdatePovCharacterAsync(
|
||||
chapterMapping.ChapterID,
|
||||
defaultCharacter?.CharacterID,
|
||||
"Structural",
|
||||
sample.SuggestedPovConfidence,
|
||||
sample.SuggestedPovEvidence,
|
||||
defaultCharacter is null ? defaultSuggestion.Key : null);
|
||||
}
|
||||
|
||||
foreach (var scene in chapterScenes)
|
||||
{
|
||||
var suggestedName = Clean(scene.SuggestedPovName);
|
||||
if (string.IsNullOrWhiteSpace(suggestedName) || !sceneMap.TryGetValue(scene.TemporarySceneKey, out var mappedScene))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
characterIndex.TryGetValue(suggestedName, out var character);
|
||||
await scenes.UpdatePovCharacterAsync(
|
||||
mappedScene.SceneID,
|
||||
character?.CharacterID,
|
||||
scene.SuggestedPovIsOverride ? "StructuralOverride" : "StructuralInherited",
|
||||
scene.SuggestedPovConfidence,
|
||||
scene.SuggestedPovEvidence,
|
||||
character is null ? suggestedName : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, Character>> BuildCanonicalCharacterNameIndexAsync(int projectId)
|
||||
{
|
||||
var projectCharacters = await characters.ListCharactersAsync(projectId);
|
||||
var index = new Dictionary<string, Character>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var character in projectCharacters)
|
||||
{
|
||||
AddCharacterName(index, character.CharacterName, character);
|
||||
AddCharacterName(index, character.ShortName, character);
|
||||
var firstName = character.CharacterName.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||
AddCharacterName(index, firstName, character);
|
||||
|
||||
foreach (var alias in await characters.ListAliasesAsync(character.CharacterID))
|
||||
{
|
||||
AddCharacterName(index, alias.Alias, character);
|
||||
AddCharacterName(index, alias.Alias.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), character);
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
private static void AddCharacterName(IDictionary<string, Character> index, string? name, Character character)
|
||||
{
|
||||
var clean = Clean(name);
|
||||
if (!string.IsNullOrWhiteSpace(clean) && !index.ContainsKey(clean))
|
||||
{
|
||||
index.Add(clean, character);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<OnboardingManuscriptBuildResult> BuildExistingStructureResultAsync(
|
||||
int userId,
|
||||
ManuscriptScanPreview preview,
|
||||
|
||||
@ -19,6 +19,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
IStoryIntelligencePipelineStateService pipelineState,
|
||||
INameIntelligenceService nameIntelligence,
|
||||
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
||||
ICurrentUserService currentUser,
|
||||
ILogger<StoryIntelligenceCharacterImportService> logger) : IStoryIntelligenceCharacterImportService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
@ -317,6 +318,11 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
}
|
||||
|
||||
targetName ??= $"Character {targetCharacterId.Value:N0}";
|
||||
if (candidate.ExistingCharacterID.HasValue && candidate.ExistingCharacterID.Value != targetCharacterId.Value)
|
||||
{
|
||||
await characters.MergeAsync(candidate.ExistingCharacterID.Value, targetCharacterId.Value, currentUser.UserId);
|
||||
}
|
||||
|
||||
await TryAddAliasAsync(targetCharacterId.Value, candidate.DisplayName, targetName);
|
||||
AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId.Value);
|
||||
foreach (var alias in candidate.Aliases)
|
||||
@ -543,7 +549,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
}
|
||||
|
||||
var povName = CleanCharacterName(parsed.PointOfView?.CharacterName);
|
||||
if (IsCharacterNameCandidate(povName))
|
||||
if (IsCharacterNameCandidate(povName) && !importedScene.POVCharacterID.HasValue)
|
||||
{
|
||||
AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter
|
||||
{
|
||||
@ -619,7 +625,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
IReadOnlyList<SceneCharacter> existingAppearances)
|
||||
{
|
||||
var cleanName = CleanCharacterName(name);
|
||||
if (!IsCharacterNameCandidate(cleanName))
|
||||
if (!IsCharacterNameCandidate(cleanName) || IsResolvedPovReference(cleanName, importedScene))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@ -634,6 +640,10 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
}, existingAppearances);
|
||||
}
|
||||
|
||||
private static bool IsResolvedPovReference(string name, Scene importedScene)
|
||||
=> importedScene.POVCharacterID.HasValue
|
||||
&& (IgnoredNames.Contains(Normalise(name)) || string.Equals(name, "Narrator", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private async Task<CharacterIndex> BuildCharacterIndexAsync(int projectId)
|
||||
{
|
||||
var index = new CharacterIndex();
|
||||
|
||||
550
PlotLine/Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql
Normal file
550
PlotLine/Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql
Normal file
@ -0,0 +1,550 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Chapters', N'POVSource') IS NULL
|
||||
ALTER TABLE dbo.Chapters ADD POVSource nvarchar(40) NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Chapters', N'POVConfidence') IS NULL
|
||||
ALTER TABLE dbo.Chapters ADD POVConfidence decimal(5,2) NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Chapters', N'POVEvidence') IS NULL
|
||||
ALTER TABLE dbo.Chapters ADD POVEvidence nvarchar(500) NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Chapters', N'POVSuggestedName') IS NULL
|
||||
ALTER TABLE dbo.Chapters ADD POVSuggestedName nvarchar(200) NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Scenes', N'POVSource') IS NULL
|
||||
ALTER TABLE dbo.Scenes ADD POVSource nvarchar(40) NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Scenes', N'POVConfidence') IS NULL
|
||||
ALTER TABLE dbo.Scenes ADD POVConfidence decimal(5,2) NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Scenes', N'POVEvidence') IS NULL
|
||||
ALTER TABLE dbo.Scenes ADD POVEvidence nvarchar(500) NULL;
|
||||
GO
|
||||
|
||||
IF COL_LENGTH(N'dbo.Scenes', N'POVSuggestedName') IS NULL
|
||||
ALTER TABLE dbo.Scenes ADD POVSuggestedName nvarchar(200) NULL;
|
||||
GO
|
||||
|
||||
UPDATE dbo.Chapters
|
||||
SET POVSource = N'Legacy'
|
||||
WHERE POVCharacterID IS NOT NULL
|
||||
AND POVSource IS NULL;
|
||||
GO
|
||||
|
||||
UPDATE dbo.Scenes
|
||||
SET POVSource = N'Legacy'
|
||||
WHERE POVCharacterID IS NOT NULL
|
||||
AND POVSource IS NULL;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Chapter_ListByBook
|
||||
@BookID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID,
|
||||
pov.CharacterName AS POVCharacterName, c.POVSource, c.POVConfidence, c.POVEvidence, c.POVSuggestedName,
|
||||
c.Summary, c.ChapterPurposeID, cpt.PurposeName AS ChapterPurposeName,
|
||||
c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName,
|
||||
c.CreatedDate, c.UpdatedDate, c.IsArchived, c.IsLinkedToManuscript,
|
||||
c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc
|
||||
FROM dbo.Chapters c
|
||||
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID
|
||||
LEFT JOIN dbo.ChapterPurposeTypes cpt ON cpt.ChapterPurposeID = c.ChapterPurposeID
|
||||
LEFT JOIN dbo.Characters pov ON pov.CharacterID = c.POVCharacterID
|
||||
WHERE c.BookID = @BookID AND c.IsArchived = 0
|
||||
ORDER BY c.SortOrder, c.ChapterNumber;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Chapter_Get
|
||||
@ChapterID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID,
|
||||
pov.CharacterName AS POVCharacterName, c.POVSource, c.POVConfidence, c.POVEvidence, c.POVSuggestedName,
|
||||
c.Summary, c.ChapterPurposeID, cpt.PurposeName AS ChapterPurposeName,
|
||||
c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName,
|
||||
c.CreatedDate, c.UpdatedDate, c.IsArchived, c.IsLinkedToManuscript,
|
||||
c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc
|
||||
FROM dbo.Chapters c
|
||||
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID
|
||||
LEFT JOIN dbo.ChapterPurposeTypes cpt ON cpt.ChapterPurposeID = c.ChapterPurposeID
|
||||
LEFT JOIN dbo.Characters pov ON pov.CharacterID = c.POVCharacterID
|
||||
WHERE c.ChapterID = @ChapterID AND c.IsArchived = 0;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Scene_ListByChapter
|
||||
@ChapterID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.Summary, s.StructuralSummary,
|
||||
s.POVCharacterID, pov.CharacterName AS POVCharacterName, s.POVSource, s.POVConfidence, s.POVEvidence, s.POVSuggestedName,
|
||||
s.PrimaryLocationID, s.FloorPlanID, s.InitialFloorPlanFloorID, s.SortOrder,
|
||||
s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount,
|
||||
s.DurationUnitID, du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID,
|
||||
tc.TimeConfidenceName, s.ScenePurposeNotes, s.SceneOutcomeNotes, s.RevisionStatusID,
|
||||
rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived,
|
||||
s.ArchivedDate, s.ArchivedReason, s.IsLinkedToManuscript, s.ManuscriptDocumentID,
|
||||
s.ExternalReference, s.LinkedUtc, s.ImportSource, s.ImportRunID,
|
||||
s.SourceStartParagraph, s.SourceEndParagraph
|
||||
FROM dbo.Scenes s
|
||||
INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID
|
||||
LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID
|
||||
INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID
|
||||
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID
|
||||
LEFT JOIN dbo.Characters pov ON pov.CharacterID = s.POVCharacterID
|
||||
WHERE s.ChapterID = @ChapterID AND s.IsArchived = 0
|
||||
ORDER BY s.SortOrder, s.SceneNumber, s.SceneTitle;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Scene_Get
|
||||
@SceneID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
EXEC dbo.SceneMetric_EnsureDefaults @SceneID;
|
||||
SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.SceneTitleSource, s.Summary, s.StructuralSummary,
|
||||
s.POVCharacterID, pov.CharacterName AS POVCharacterName, s.POVSource, s.POVConfidence, s.POVEvidence, s.POVSuggestedName,
|
||||
s.PrimaryLocationID, s.FloorPlanID, s.InitialFloorPlanFloorID,
|
||||
location.LocationName AS PrimaryLocationName, location.LocationPath AS PrimaryLocationPath,
|
||||
s.SortOrder, s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount, s.DurationUnitID,
|
||||
du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID, tc.TimeConfidenceName, s.ScenePurposeNotes,
|
||||
s.SceneOutcomeNotes, s.RevisionStatusID, rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived,
|
||||
s.ArchivedDate, s.ArchivedReason, s.IsLinkedToManuscript, s.ManuscriptDocumentID, s.ExternalReference, s.LinkedUtc,
|
||||
s.ImportSource, s.ImportRunID, s.SourceStartParagraph, s.SourceEndParagraph
|
||||
FROM dbo.Scenes s
|
||||
INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID
|
||||
INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID
|
||||
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID
|
||||
LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID
|
||||
LEFT JOIN dbo.LocationPaths location ON location.LocationID = s.PrimaryLocationID
|
||||
LEFT JOIN dbo.Characters pov ON pov.CharacterID = s.POVCharacterID
|
||||
WHERE s.SceneID = @SceneID AND s.IsArchived = 0;
|
||||
SELECT ScenePurposeTypeID FROM dbo.ScenePurposes WHERE SceneID = @SceneID;
|
||||
EXEC dbo.SceneMetric_ListByScene @SceneID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Chapter_Save
|
||||
@ChapterID int = NULL,
|
||||
@BookID int,
|
||||
@ChapterNumber decimal(9,2),
|
||||
@ChapterTitle nvarchar(200),
|
||||
@Summary nvarchar(max) = NULL,
|
||||
@ChapterPurposeID int = NULL,
|
||||
@RevisionStatusID int,
|
||||
@POVCharacterID int = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
IF @POVCharacterID IS NOT NULL
|
||||
AND NOT EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.Characters ch
|
||||
INNER JOIN dbo.Books b ON b.ProjectID = ch.ProjectID
|
||||
WHERE ch.CharacterID = @POVCharacterID
|
||||
AND b.BookID = @BookID
|
||||
AND ch.IsArchived = 0
|
||||
)
|
||||
SET @POVCharacterID = NULL;
|
||||
|
||||
IF @ChapterID IS NULL OR @ChapterID = 0
|
||||
BEGIN
|
||||
DECLARE @NextChapterOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Chapters WHERE BookID = @BookID), 0) + 10;
|
||||
INSERT dbo.Chapters (BookID, ChapterNumber, ChapterTitle, POVCharacterID, POVSource, Summary, ChapterPurposeID, SortOrder, RevisionStatusID)
|
||||
VALUES (@BookID, @ChapterNumber, @ChapterTitle, @POVCharacterID, CASE WHEN @POVCharacterID IS NULL THEN NULL ELSE N'Author' END, @Summary, @ChapterPurposeID, @NextChapterOrder, @RevisionStatusID);
|
||||
SELECT CAST(SCOPE_IDENTITY() AS int) AS ChapterID;
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
UPDATE dbo.Chapters
|
||||
SET ChapterNumber = @ChapterNumber,
|
||||
ChapterTitle = @ChapterTitle,
|
||||
POVCharacterID = @POVCharacterID,
|
||||
POVSource = CASE
|
||||
WHEN ISNULL(POVCharacterID, 0) <> ISNULL(@POVCharacterID, 0) THEN CASE WHEN @POVCharacterID IS NULL THEN NULL ELSE N'Author' END
|
||||
ELSE POVSource
|
||||
END,
|
||||
Summary = @Summary,
|
||||
ChapterPurposeID = @ChapterPurposeID,
|
||||
RevisionStatusID = @RevisionStatusID,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE ChapterID = @ChapterID;
|
||||
|
||||
SELECT @ChapterID AS ChapterID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Chapter_UpdatePovCharacter
|
||||
@ChapterID int,
|
||||
@POVCharacterID int = NULL,
|
||||
@POVSource nvarchar(40) = N'Author',
|
||||
@POVConfidence decimal(5,2) = NULL,
|
||||
@POVEvidence nvarchar(500) = NULL,
|
||||
@POVSuggestedName nvarchar(200) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @BookID int, @ProjectID int;
|
||||
SELECT @BookID = c.BookID, @ProjectID = b.ProjectID
|
||||
FROM dbo.Chapters c
|
||||
INNER JOIN dbo.Books b ON b.BookID = c.BookID
|
||||
WHERE c.ChapterID = @ChapterID;
|
||||
|
||||
IF @BookID IS NULL
|
||||
THROW 51000, 'Chapter could not be found.', 1;
|
||||
|
||||
IF @POVCharacterID IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM dbo.Characters WHERE CharacterID = @POVCharacterID AND ProjectID = @ProjectID AND IsArchived = 0)
|
||||
THROW 51000, 'POV character does not belong to this project.', 1;
|
||||
|
||||
UPDATE dbo.Chapters
|
||||
SET POVCharacterID = @POVCharacterID,
|
||||
POVSource = CASE WHEN @POVCharacterID IS NULL AND NULLIF(@POVSuggestedName, N'') IS NULL THEN NULL ELSE @POVSource END,
|
||||
POVConfidence = @POVConfidence,
|
||||
POVEvidence = NULLIF(LTRIM(RTRIM(@POVEvidence)), N''),
|
||||
POVSuggestedName = NULLIF(LTRIM(RTRIM(@POVSuggestedName)), N''),
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE ChapterID = @ChapterID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Scene_Save
|
||||
@SceneID int = NULL, @ChapterID int, @SceneNumber decimal(10,2), @SceneTitle nvarchar(200), @Summary nvarchar(max) = NULL,
|
||||
@TimeModeID int, @StartDateTime datetime2 = NULL, @EndDateTime datetime2 = NULL, @DurationAmount decimal(10,2) = NULL,
|
||||
@DurationUnitID int = NULL, @RelativeTimeText nvarchar(200) = NULL, @TimeConfidenceID int,
|
||||
@ScenePurposeNotes nvarchar(max) = NULL, @SceneOutcomeNotes nvarchar(max) = NULL, @RevisionStatusID int,
|
||||
@PrimaryLocationID int = NULL, @FloorPlanID int = NULL, @InitialFloorPlanFloorID int = NULL,
|
||||
@POVCharacterID int = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @ProjectID int;
|
||||
SELECT @ProjectID = b.ProjectID
|
||||
FROM dbo.Chapters c
|
||||
INNER JOIN dbo.Books b ON b.BookID = c.BookID
|
||||
WHERE c.ChapterID = @ChapterID;
|
||||
|
||||
IF @POVCharacterID IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM dbo.Characters WHERE CharacterID = @POVCharacterID AND ProjectID = @ProjectID AND IsArchived = 0)
|
||||
SET @POVCharacterID = NULL;
|
||||
|
||||
DECLARE @NextSortOrder int;
|
||||
DECLARE @CleanSceneTitle nvarchar(200) = ISNULL(LEFT(LTRIM(RTRIM(@SceneTitle)), 200), N'');
|
||||
IF @SceneID IS NULL OR @SceneID = 0
|
||||
BEGIN
|
||||
SELECT @NextSortOrder = ISNULL(MAX(SortOrder), 0) + 10 FROM dbo.Scenes WHERE ChapterID = @ChapterID;
|
||||
INSERT dbo.Scenes (ChapterID, SceneNumber, SceneTitle, SceneTitleSource, Summary, POVCharacterID, POVSource, SortOrder, TimeModeID, StartDateTime, EndDateTime,
|
||||
DurationAmount, DurationUnitID, RelativeTimeText, TimeConfidenceID, ScenePurposeNotes, SceneOutcomeNotes,
|
||||
RevisionStatusID, PrimaryLocationID, FloorPlanID, InitialFloorPlanFloorID)
|
||||
VALUES (@ChapterID, @SceneNumber, @CleanSceneTitle, CASE WHEN NULLIF(@CleanSceneTitle, N'') IS NULL THEN NULL ELSE N'Author' END, @Summary, @POVCharacterID, CASE WHEN @POVCharacterID IS NULL THEN NULL ELSE N'Author' END, @NextSortOrder, @TimeModeID, @StartDateTime, @EndDateTime,
|
||||
@DurationAmount, @DurationUnitID, @RelativeTimeText, @TimeConfidenceID, @ScenePurposeNotes, @SceneOutcomeNotes,
|
||||
@RevisionStatusID, @PrimaryLocationID, @FloorPlanID, @InitialFloorPlanFloorID);
|
||||
SET @SceneID = CAST(SCOPE_IDENTITY() AS int);
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
UPDATE dbo.Scenes
|
||||
SET SceneNumber = @SceneNumber,
|
||||
SceneTitle = @CleanSceneTitle,
|
||||
SceneTitleSource = CASE
|
||||
WHEN ISNULL(SceneTitle, N'') <> @CleanSceneTitle THEN CASE WHEN NULLIF(@CleanSceneTitle, N'') IS NULL THEN NULL ELSE N'Author' END
|
||||
ELSE SceneTitleSource
|
||||
END,
|
||||
Summary = @Summary,
|
||||
POVCharacterID = @POVCharacterID,
|
||||
POVSource = CASE
|
||||
WHEN ISNULL(POVCharacterID, 0) <> ISNULL(@POVCharacterID, 0) THEN CASE WHEN @POVCharacterID IS NULL THEN NULL ELSE N'Author' END
|
||||
ELSE POVSource
|
||||
END,
|
||||
TimeModeID = @TimeModeID,
|
||||
StartDateTime = @StartDateTime,
|
||||
EndDateTime = @EndDateTime,
|
||||
DurationAmount = @DurationAmount,
|
||||
DurationUnitID = @DurationUnitID,
|
||||
RelativeTimeText = @RelativeTimeText,
|
||||
TimeConfidenceID = @TimeConfidenceID,
|
||||
ScenePurposeNotes = @ScenePurposeNotes,
|
||||
SceneOutcomeNotes = @SceneOutcomeNotes,
|
||||
RevisionStatusID = @RevisionStatusID,
|
||||
PrimaryLocationID = @PrimaryLocationID,
|
||||
FloorPlanID = @FloorPlanID,
|
||||
InitialFloorPlanFloorID = @InitialFloorPlanFloorID,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE SceneID = @SceneID;
|
||||
END
|
||||
SELECT @SceneID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Scene_UpdatePovCharacter
|
||||
@SceneID int,
|
||||
@POVCharacterID int = NULL,
|
||||
@POVSource nvarchar(40) = N'Author',
|
||||
@POVConfidence decimal(5,2) = NULL,
|
||||
@POVEvidence nvarchar(500) = NULL,
|
||||
@POVSuggestedName nvarchar(200) = NULL,
|
||||
@OverwriteAuthor bit = 0
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @ProjectID int, @ExistingSource nvarchar(40);
|
||||
SELECT @ProjectID = b.ProjectID, @ExistingSource = s.POVSource
|
||||
FROM dbo.Scenes s
|
||||
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
|
||||
INNER JOIN dbo.Books b ON b.BookID = c.BookID
|
||||
WHERE s.SceneID = @SceneID;
|
||||
|
||||
IF @ProjectID IS NULL
|
||||
THROW 51000, 'Scene could not be found.', 1;
|
||||
|
||||
IF @POVCharacterID IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM dbo.Characters WHERE CharacterID = @POVCharacterID AND ProjectID = @ProjectID AND IsArchived = 0)
|
||||
THROW 51000, 'POV character does not belong to this project.', 1;
|
||||
|
||||
IF @ExistingSource = N'Author' AND ISNULL(@POVSource, N'') <> N'Author' AND @OverwriteAuthor = 0
|
||||
RETURN;
|
||||
|
||||
UPDATE dbo.Scenes
|
||||
SET POVCharacterID = @POVCharacterID,
|
||||
POVSource = CASE WHEN @POVCharacterID IS NULL AND NULLIF(@POVSuggestedName, N'') IS NULL THEN NULL ELSE @POVSource END,
|
||||
POVConfidence = @POVConfidence,
|
||||
POVEvidence = NULLIF(LTRIM(RTRIM(@POVEvidence)), N''),
|
||||
POVSuggestedName = NULLIF(LTRIM(RTRIM(@POVSuggestedName)), N''),
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE SceneID = @SceneID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Chapter_SetScenesPov
|
||||
@ChapterID int,
|
||||
@POVCharacterID int = NULL,
|
||||
@OverwriteAuthor bit = 0
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @ProjectID int;
|
||||
SELECT @ProjectID = b.ProjectID
|
||||
FROM dbo.Chapters c
|
||||
INNER JOIN dbo.Books b ON b.BookID = c.BookID
|
||||
WHERE c.ChapterID = @ChapterID;
|
||||
|
||||
IF @ProjectID IS NULL
|
||||
THROW 51000, 'Chapter could not be found.', 1;
|
||||
|
||||
IF @POVCharacterID IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM dbo.Characters WHERE CharacterID = @POVCharacterID AND ProjectID = @ProjectID AND IsArchived = 0)
|
||||
THROW 51000, 'POV character does not belong to this project.', 1;
|
||||
|
||||
UPDATE dbo.Scenes
|
||||
SET POVCharacterID = @POVCharacterID,
|
||||
POVSource = CASE WHEN @POVCharacterID IS NULL THEN NULL ELSE N'Author' END,
|
||||
POVConfidence = NULL,
|
||||
POVEvidence = NULL,
|
||||
POVSuggestedName = NULL,
|
||||
UpdatedDate = SYSUTCDATETIME()
|
||||
WHERE ChapterID = @ChapterID
|
||||
AND IsArchived = 0
|
||||
AND (@OverwriteAuthor = 1 OR ISNULL(POVSource, N'') <> N'Author');
|
||||
|
||||
SELECT @@ROWCOUNT AS ScenesUpdated;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Character_Merge
|
||||
@SourceCharacterID int,
|
||||
@TargetCharacterID int,
|
||||
@UserID int = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF @SourceCharacterID IS NULL OR @TargetCharacterID IS NULL OR @SourceCharacterID = @TargetCharacterID
|
||||
THROW 51000, 'Choose two different characters to merge.', 1;
|
||||
|
||||
DECLARE @ProjectID int, @SourceName nvarchar(400), @TargetName nvarchar(400), @Now datetime2 = SYSUTCDATETIME();
|
||||
SELECT @ProjectID = ProjectID, @SourceName = CharacterName FROM dbo.Characters WHERE CharacterID = @SourceCharacterID AND IsArchived = 0;
|
||||
SELECT @TargetName = CharacterName FROM dbo.Characters WHERE CharacterID = @TargetCharacterID AND IsArchived = 0;
|
||||
|
||||
IF @ProjectID IS NULL OR @TargetName IS NULL
|
||||
THROW 51000, 'Both characters must exist and be active before they can be merged.', 1;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.Characters WHERE CharacterID = @TargetCharacterID AND ProjectID = @ProjectID)
|
||||
THROW 51000, 'Characters must belong to the same project before they can be merged.', 1;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
INSERT dbo.CharacterAliases (CharacterID, Alias, SortOrder)
|
||||
SELECT @TargetCharacterID, AliasValue, ISNULL((SELECT MAX(SortOrder) FROM dbo.CharacterAliases WHERE CharacterID = @TargetCharacterID), 0) + ROW_NUMBER() OVER (ORDER BY AliasValue) * 10
|
||||
FROM
|
||||
(
|
||||
SELECT LTRIM(RTRIM(@SourceName)) AS AliasValue
|
||||
UNION
|
||||
SELECT LTRIM(RTRIM(ShortName)) FROM dbo.Characters WHERE CharacterID = @SourceCharacterID
|
||||
UNION
|
||||
SELECT LTRIM(RTRIM(Alias)) FROM dbo.CharacterAliases WHERE CharacterID = @SourceCharacterID
|
||||
) aliases
|
||||
WHERE NULLIF(AliasValue, N'') IS NOT NULL
|
||||
AND AliasValue <> @TargetName
|
||||
AND NOT EXISTS
|
||||
(
|
||||
SELECT 1 FROM dbo.CharacterAliases existing
|
||||
WHERE existing.CharacterID = @TargetCharacterID
|
||||
AND UPPER(LTRIM(RTRIM(existing.Alias))) = UPPER(AliasValue)
|
||||
);
|
||||
|
||||
UPDATE dbo.Chapters SET POVCharacterID = @TargetCharacterID, UpdatedDate = @Now WHERE POVCharacterID = @SourceCharacterID;
|
||||
UPDATE dbo.Scenes SET POVCharacterID = @TargetCharacterID, UpdatedDate = @Now WHERE POVCharacterID = @SourceCharacterID;
|
||||
|
||||
DELETE sourceRows
|
||||
FROM dbo.SceneCharacters sourceRows
|
||||
WHERE sourceRows.CharacterID = @SourceCharacterID
|
||||
AND EXISTS
|
||||
(
|
||||
SELECT 1 FROM dbo.SceneCharacters targetRows
|
||||
WHERE targetRows.SceneID = sourceRows.SceneID
|
||||
AND targetRows.CharacterID = @TargetCharacterID
|
||||
);
|
||||
UPDATE dbo.SceneCharacters SET CharacterID = @TargetCharacterID, UpdatedDate = @Now WHERE CharacterID = @SourceCharacterID;
|
||||
|
||||
DELETE sourceRows
|
||||
FROM dbo.SceneCharacterSuggestions sourceRows
|
||||
WHERE sourceRows.CharacterID = @SourceCharacterID
|
||||
AND EXISTS
|
||||
(
|
||||
SELECT 1 FROM dbo.SceneCharacterSuggestions targetRows
|
||||
WHERE targetRows.SceneID = sourceRows.SceneID
|
||||
AND targetRows.CharacterID = @TargetCharacterID
|
||||
AND targetRows.Status = sourceRows.Status
|
||||
);
|
||||
UPDATE dbo.SceneCharacterSuggestions SET CharacterID = @TargetCharacterID WHERE CharacterID = @SourceCharacterID;
|
||||
|
||||
DELETE sourceRows
|
||||
FROM dbo.SceneFloorPlanOccupancy sourceRows
|
||||
WHERE sourceRows.CharacterID = @SourceCharacterID
|
||||
AND EXISTS
|
||||
(
|
||||
SELECT 1 FROM dbo.SceneFloorPlanOccupancy targetRows
|
||||
WHERE targetRows.SceneID = sourceRows.SceneID
|
||||
AND targetRows.CharacterID = @TargetCharacterID
|
||||
);
|
||||
UPDATE dbo.SceneFloorPlanOccupancy SET CharacterID = @TargetCharacterID, ModifiedDate = @Now WHERE CharacterID = @SourceCharacterID;
|
||||
|
||||
UPDATE dbo.AssetCustodyEventCharacters SET CharacterID = @TargetCharacterID WHERE CharacterID = @SourceCharacterID;
|
||||
UPDATE dbo.CharacterAttributeEvents SET CharacterID = @TargetCharacterID, UpdatedDate = @Now WHERE CharacterID = @SourceCharacterID;
|
||||
UPDATE dbo.CharacterKnowledge SET CharacterID = @TargetCharacterID, UpdatedDate = @Now WHERE CharacterID = @SourceCharacterID;
|
||||
UPDATE dbo.CharacterImages SET CharacterID = @TargetCharacterID WHERE CharacterID = @SourceCharacterID;
|
||||
UPDATE dbo.ContinuityWarningAcknowledgements
|
||||
SET CharacterID = @TargetCharacterID
|
||||
WHERE CharacterID = @SourceCharacterID;
|
||||
UPDATE dbo.OnboardingManuscriptBuildCharacterMap SET CharacterID = @TargetCharacterID WHERE CharacterID = @SourceCharacterID;
|
||||
|
||||
DECLARE relationship_cursor CURSOR LOCAL FAST_FORWARD FOR
|
||||
SELECT CharacterRelationshipID,
|
||||
CASE WHEN CharacterAID = @SourceCharacterID THEN @TargetCharacterID ELSE CharacterAID END AS NewAID,
|
||||
CASE WHEN CharacterBID = @SourceCharacterID THEN @TargetCharacterID ELSE CharacterBID END AS NewBID
|
||||
FROM dbo.CharacterRelationships
|
||||
WHERE IsArchived = 0
|
||||
AND (CharacterAID = @SourceCharacterID OR CharacterBID = @SourceCharacterID);
|
||||
|
||||
DECLARE @RelationshipID int, @NewAID int, @NewBID int, @ExistingRelationshipID int;
|
||||
OPEN relationship_cursor;
|
||||
FETCH NEXT FROM relationship_cursor INTO @RelationshipID, @NewAID, @NewBID;
|
||||
WHILE @@FETCH_STATUS = 0
|
||||
BEGIN
|
||||
IF @NewAID = @NewBID
|
||||
BEGIN
|
||||
UPDATE dbo.CharacterRelationships
|
||||
SET IsArchived = 1,
|
||||
ArchivedDate = @Now,
|
||||
ArchivedReason = N'Archived during character merge to avoid a self-relationship.',
|
||||
UpdatedDate = @Now,
|
||||
ModifiedByUserID = @UserID,
|
||||
ModifiedDateUTC = @Now
|
||||
WHERE CharacterRelationshipID = @RelationshipID;
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
SELECT TOP (1) @ExistingRelationshipID = CharacterRelationshipID
|
||||
FROM dbo.CharacterRelationships
|
||||
WHERE ProjectID = @ProjectID
|
||||
AND IsArchived = 0
|
||||
AND CharacterRelationshipID <> @RelationshipID
|
||||
AND ((CharacterAID = @NewAID AND CharacterBID = @NewBID) OR (CharacterAID = @NewBID AND CharacterBID = @NewAID))
|
||||
ORDER BY CharacterRelationshipID;
|
||||
|
||||
IF @ExistingRelationshipID IS NOT NULL
|
||||
BEGIN
|
||||
UPDATE dbo.RelationshipEvents
|
||||
SET CharacterRelationshipID = @ExistingRelationshipID,
|
||||
UpdatedDate = @Now,
|
||||
ModifiedByUserID = @UserID,
|
||||
ModifiedDateUTC = @Now
|
||||
WHERE CharacterRelationshipID = @RelationshipID;
|
||||
|
||||
UPDATE dbo.CharacterRelationships
|
||||
SET IsArchived = 1,
|
||||
ArchivedDate = @Now,
|
||||
ArchivedReason = N'Merged into another relationship during character merge.',
|
||||
UpdatedDate = @Now,
|
||||
ModifiedByUserID = @UserID,
|
||||
ModifiedDateUTC = @Now
|
||||
WHERE CharacterRelationshipID = @RelationshipID;
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
UPDATE dbo.CharacterRelationships
|
||||
SET CharacterAID = @NewAID,
|
||||
CharacterBID = @NewBID,
|
||||
UpdatedDate = @Now,
|
||||
ModifiedByUserID = @UserID,
|
||||
ModifiedDateUTC = @Now
|
||||
WHERE CharacterRelationshipID = @RelationshipID;
|
||||
END
|
||||
END
|
||||
|
||||
SET @ExistingRelationshipID = NULL;
|
||||
FETCH NEXT FROM relationship_cursor INTO @RelationshipID, @NewAID, @NewBID;
|
||||
END
|
||||
CLOSE relationship_cursor;
|
||||
DEALLOCATE relationship_cursor;
|
||||
|
||||
UPDATE dbo.Characters
|
||||
SET IsArchived = 1,
|
||||
ArchivedDate = @Now,
|
||||
ArchivedReason = CONCAT(N'Merged into ', @TargetName, N' (CharacterID ', @TargetCharacterID, N').'),
|
||||
UpdatedDate = @Now,
|
||||
ModifiedByUserID = @UserID,
|
||||
ModifiedDateUTC = @Now
|
||||
WHERE CharacterID = @SourceCharacterID;
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
SELECT @TargetCharacterID AS TargetCharacterID,
|
||||
@SourceCharacterID AS SourceCharacterID,
|
||||
@SourceName AS SourceCharacterName,
|
||||
@TargetName AS TargetCharacterName;
|
||||
END;
|
||||
GO
|
||||
@ -275,6 +275,9 @@ public sealed class ChapterEditViewModel
|
||||
[Display(Name = "Chapter purpose")]
|
||||
public int? ChapterPurposeID { get; set; }
|
||||
|
||||
[Display(Name = "POV character")]
|
||||
public int? POVCharacterID { get; set; }
|
||||
|
||||
[Display(Name = "Revision status")]
|
||||
public int RevisionStatusID { get; set; }
|
||||
|
||||
@ -284,6 +287,7 @@ public sealed class ChapterEditViewModel
|
||||
public Project? Project { get; set; }
|
||||
public IReadOnlyList<SelectListItem> RevisionStatuses { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> ChapterPurposes { get; set; } = [];
|
||||
public IReadOnlyList<SelectListItem> CharacterOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ChapterDetailViewModel
|
||||
@ -296,6 +300,7 @@ public sealed class ChapterDetailViewModel
|
||||
public int PendingCharacterSuggestionCount { get; set; }
|
||||
public int PendingAssetSuggestionCount { get; set; }
|
||||
public int PendingLocationSuggestionCount { get; set; }
|
||||
public IReadOnlyList<SelectListItem> CharacterOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class SceneEditViewModel
|
||||
@ -1865,6 +1870,21 @@ public sealed class CharacterDetailViewModel
|
||||
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class CharacterMergeViewModel
|
||||
{
|
||||
public Project Project { get; set; } = new();
|
||||
public Character SourceCharacter { get; set; } = new();
|
||||
public CharacterMergeForm Form { get; set; } = new();
|
||||
public IReadOnlyList<SelectListItem> TargetOptions { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class CharacterMergeForm
|
||||
{
|
||||
public int SourceCharacterID { get; set; }
|
||||
public int TargetCharacterID { get; set; }
|
||||
public bool Confirm { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CharacterImageUploadViewModel
|
||||
{
|
||||
public int CharacterID { get; set; }
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
{
|
||||
<div class="alert alert-info">@archiveMessage</div>
|
||||
}
|
||||
@if (TempData["ChapterMessage"] is string chapterMessage)
|
||||
{
|
||||
<div class="alert alert-info">@chapterMessage</div>
|
||||
}
|
||||
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
@ -30,6 +34,12 @@
|
||||
{
|
||||
<span class="status-pill">@Model.Chapter.ChapterPurposeName</span>
|
||||
}
|
||||
@{
|
||||
var chapterPov = Model.CharacterOptions.FirstOrDefault(option => option.Value == Model.Chapter.POVCharacterID?.ToString())?.Text
|
||||
?? Model.Chapter.POVSuggestedName
|
||||
?? "Not detected";
|
||||
}
|
||||
<span class="status-pill">POV: @chapterPov</span>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary))
|
||||
{
|
||||
<p class="lead-text">@Model.Chapter.Summary</p>
|
||||
@ -76,6 +86,22 @@
|
||||
|
||||
<section class="list-section">
|
||||
<h2>Scenes <help-icon key="chapters.scenes" /></h2>
|
||||
<form asp-action="SetScenesPov" method="post" class="row g-2 align-items-end mb-3">
|
||||
<input type="hidden" name="chapterId" value="@Model.Chapter.ChapterID" />
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Chapter POV</label>
|
||||
<select class="form-select form-select-sm" name="povCharacterId" asp-items="Model.CharacterOptions"></select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="overwriteAuthor" value="true" />
|
||||
<span class="form-check-label">Overwrite author scene POV</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Apply to scenes</button>
|
||||
</div>
|
||||
</form>
|
||||
@if (Model.TextStatistics is not null && Model.TextStatistics.TotalWordCount > 0)
|
||||
{
|
||||
<partial name="~/Views/Shared/_ManuscriptTextBalance.cshtml" model="Model.TextStatistics" />
|
||||
@ -94,6 +120,7 @@
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Manuscript</th>
|
||||
<th>POV</th>
|
||||
<th>Time</th>
|
||||
<th>Summary</th>
|
||||
<th>Order</th>
|
||||
@ -127,6 +154,7 @@
|
||||
</td>
|
||||
<td><span class="status-pill">@scene.RevisionStatusName</span></td>
|
||||
<td><span class="status-pill">@(scene.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned")</span></td>
|
||||
<td>@(scene.POVCharacterName ?? scene.POVSuggestedName ?? "Not detected")</td>
|
||||
<td>@scene.TimeLabel</td>
|
||||
<td>@scene.Summary</td>
|
||||
<td>
|
||||
|
||||
@ -33,6 +33,10 @@
|
||||
<label asp-for="ChapterPurposeID" class="form-label"></label>
|
||||
<select asp-for="ChapterPurposeID" asp-items="Model.ChapterPurposes" class="form-select"></select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="POVCharacterID" class="form-label"></label>
|
||||
<select asp-for="POVCharacterID" asp-items="Model.CharacterOptions" class="form-select"></select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label asp-for="Summary" class="form-label"></label>
|
||||
<textarea asp-for="Summary" class="form-control" rows="4"></textarea>
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<a class="dropdown-item" asp-action="Edit" asp-route-id="@Model.Character.CharacterID">Edit character</a>
|
||||
<a class="dropdown-item" asp-action="Merge" asp-route-id="@Model.Character.CharacterID">Merge with another character</a>
|
||||
<a class="dropdown-item" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID">Export character</a>
|
||||
<a class="dropdown-item" asp-controller="Exports" asp-action="Character" asp-route-id="@Model.Character.CharacterID" asp-route-format="markdown">Markdown</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
@ -72,6 +73,10 @@
|
||||
{
|
||||
<div class="alert alert-info">@relationshipMessage</div>
|
||||
}
|
||||
@if (TempData["CharacterMessage"] is string characterMessage)
|
||||
{
|
||||
<div class="alert alert-info">@characterMessage</div>
|
||||
}
|
||||
|
||||
<section id="character-overview" class="asset-detail-grid">
|
||||
<article class="plain-card">
|
||||
|
||||
48
PlotLine/Views/Characters/Merge.cshtml
Normal file
48
PlotLine/Views/Characters/Merge.cshtml
Normal file
@ -0,0 +1,48 @@
|
||||
@model CharacterMergeViewModel
|
||||
@{
|
||||
ViewData["Title"] = $"Merge {Model.SourceCharacter.CharacterName}";
|
||||
ViewData["ProjectSection"] = "Characters";
|
||||
}
|
||||
|
||||
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
|
||||
<a asp-controller="Projects" asp-action="Index">Projects</a>
|
||||
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
|
||||
<a asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
|
||||
<a asp-action="Details" asp-route-id="@Model.SourceCharacter.CharacterID">@Model.SourceCharacter.CharacterName</a>
|
||||
<span>Merge</span>
|
||||
</nav>
|
||||
|
||||
<partial name="_ProjectSectionNav" model="Model.Project" />
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Character identity</p>
|
||||
<h1>Merge @Model.SourceCharacter.CharacterName</h1>
|
||||
<p class="lead-text">References, aliases, scene appearances, POV links, relationship events and knowledge are moved to the canonical character. The source character is archived.</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" asp-action="Details" asp-route-id="@Model.SourceCharacter.CharacterID">Cancel</a>
|
||||
</div>
|
||||
|
||||
<form asp-action="Merge" method="post" class="edit-panel">
|
||||
<input asp-for="Form.SourceCharacterID" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label asp-for="Form.TargetCharacterID" class="form-label">Canonical character</label>
|
||||
<select asp-for="Form.TargetCharacterID" asp-items="Model.TargetOptions" class="form-select">
|
||||
<option value="">Choose character</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-check">
|
||||
<input asp-for="Form.Confirm" class="form-check-input" />
|
||||
<span class="form-check-label">I understand this will archive @Model.SourceCharacter.CharacterName and move references to the selected character.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-danger mt-3" type="submit">Merge character</button>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user