Fix character review defaults
This commit is contained in:
parent
2bb5decf63
commit
1dfc620e00
@ -74,6 +74,12 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Name Intelligence reference data is copied on publish", NameIntelligenceReferenceDataIsCopiedOnPublish),
|
("Name Intelligence reference data is copied on publish", NameIntelligenceReferenceDataIsCopiedOnPublish),
|
||||||
("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures),
|
("Name Intelligence repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures),
|
||||||
("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired),
|
("Name Intelligence review warning is wired", NameIntelligenceReviewWarningIsWired),
|
||||||
|
("Story Intelligence character defaults link proposed existing matches", StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches),
|
||||||
|
("Story Intelligence character defaults ignore generic references", StoryIntelligenceCharacterDefaultsIgnoreGenericReferences),
|
||||||
|
("Story Intelligence character defaults keep descriptive identities", StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities),
|
||||||
|
("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch),
|
||||||
|
("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults),
|
||||||
|
("Story Intelligence character review reuses structured appearance aliases", StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases),
|
||||||
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
("Scan review post supports full-book form submissions", ScanReviewPostSupportsFullBookFormSubmissions),
|
||||||
("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions),
|
("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions),
|
||||||
("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation),
|
("StoryPosition builds canonical runtime scene navigation", StoryPositionBuildsCanonicalRuntimeSceneNavigation),
|
||||||
@ -1926,6 +1932,67 @@ static void NameIntelligenceReviewWarningIsWired()
|
|||||||
Assert(view.Contains("Name warning", StringComparison.Ordinal), "Character review view should render the name warning.");
|
Assert(view.Contains("Name warning", StringComparison.Ordinal), "Character review view should render the name warning.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceCharacterDefaultsLinkProposedExistingMatches()
|
||||||
|
{
|
||||||
|
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Beth", 42) == StoryIntelligenceCharacterImportActions.LinkExisting,
|
||||||
|
"A proposed existing character match should default to Link Existing.");
|
||||||
|
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Mum", 42) == StoryIntelligenceCharacterImportActions.LinkExisting,
|
||||||
|
"An explicit/proposed existing character match should beat the generic reference heuristic.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceCharacterDefaultsIgnoreGenericReferences()
|
||||||
|
{
|
||||||
|
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Mum", null) == StoryIntelligenceCharacterImportActions.Ignore,
|
||||||
|
"Family-role references should default to Ignore.");
|
||||||
|
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("One of his mates", null) == StoryIntelligenceCharacterImportActions.Ignore,
|
||||||
|
"Contextual indefinite references should default to Ignore.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceCharacterDefaultsKeepDescriptiveIdentities()
|
||||||
|
{
|
||||||
|
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Creepy guy", null) == StoryIntelligenceCharacterImportActions.CreateNew,
|
||||||
|
"Specific descriptive identities should remain create-new candidates.");
|
||||||
|
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Guy who gropes narrator", null) == StoryIntelligenceCharacterImportActions.CreateNew,
|
||||||
|
"Specific descriptive identities should not be treated as generic group references.");
|
||||||
|
Assert(StoryIntelligenceCharacterReviewDefaults.DetermineAction("Zoe", null) == StoryIntelligenceCharacterImportActions.CreateNew,
|
||||||
|
"Named new characters without an existing match should default to Create New.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceCharacterReviewPostsSelectedExistingMatch()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
var view = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceCharacters.cshtml"));
|
||||||
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
|
||||||
|
var model = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs"));
|
||||||
|
|
||||||
|
Assert(model.Contains("DefaultAction", StringComparison.Ordinal), "Character review candidates should carry a server-side default action.");
|
||||||
|
Assert(model.Contains("SelectedExistingCharacterID", StringComparison.Ordinal), "Character review candidates should carry the selected existing match.");
|
||||||
|
Assert(view.Contains("name=\"Characters[@i].ExistingCharacterID\"", StringComparison.Ordinal), "Review form should post the selected existing character.");
|
||||||
|
Assert(view.Contains("data-character-existing-panel", StringComparison.Ordinal), "Review form should render an existing-character dropdown.");
|
||||||
|
Assert(service.Contains("existingIndex.Find(choice.ExistingCharacterID)", StringComparison.Ordinal), "Import should use the posted existing-character selection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceCharacterDefaultChangesPreservePersistedResults()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
|
||||||
|
|
||||||
|
Assert(service.Contains("runs.ListSceneResultsAsync", StringComparison.Ordinal), "Review should continue to derive candidates from saved scene results.");
|
||||||
|
Assert(service.Contains("TryReadScene(sceneResult)", StringComparison.Ordinal), "Review should continue to read persisted parsed scene JSON.");
|
||||||
|
Assert(!service.Contains("SaveSceneResultAsync", StringComparison.Ordinal), "Character review defaults must not overwrite saved Story Intelligence scene results.");
|
||||||
|
Assert(!service.Contains("QueueAdminTextAsync", StringComparison.Ordinal), "Character review defaults must not queue new Story Intelligence work.");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
|
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs"));
|
||||||
|
|
||||||
|
Assert(service.Contains("parsed.CharacterAppearance", StringComparison.Ordinal), "Character review should inspect structured character appearance data.");
|
||||||
|
Assert(service.Contains("AddCharacterAppearanceAliases", StringComparison.Ordinal), "Structured appearance aliases should feed character candidates.");
|
||||||
|
Assert(service.Contains("Aliases = appearance.Aliases", StringComparison.Ordinal), "Structured appearance aliases should be reused without another AI call.");
|
||||||
|
}
|
||||||
|
|
||||||
static int CountCsvRows(string path)
|
static int CountCsvRows(string path)
|
||||||
=> File.ReadLines(path).Skip(1).Count(line => !string.IsNullOrWhiteSpace(line));
|
=> File.ReadLines(path).Skip(1).Count(line => !string.IsNullOrWhiteSpace(line));
|
||||||
|
|
||||||
|
|||||||
@ -73,6 +73,14 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
var visibleCandidates = data.Candidates.Where(candidate => !decidedKeys.Contains(candidate.Key)).ToList();
|
||||||
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
var existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
||||||
var nameWarnings = await BuildNameIntelligenceWarningsAsync(visibleCandidates, existingIndex);
|
var nameWarnings = await BuildNameIntelligenceWarningsAsync(visibleCandidates, existingIndex);
|
||||||
|
var existingOptions = existingIndex.Characters
|
||||||
|
.Select(character => new StoryIntelligenceCharacterExistingOptionViewModel
|
||||||
|
{
|
||||||
|
CharacterID = character.CharacterID,
|
||||||
|
CharacterName = character.CharacterName
|
||||||
|
})
|
||||||
|
.OrderBy(character => character.CharacterName)
|
||||||
|
.ToList();
|
||||||
return new StoryIntelligenceCharacterReviewViewModel
|
return new StoryIntelligenceCharacterReviewViewModel
|
||||||
{
|
{
|
||||||
HasCommittedScenes = data.HasCommittedScenes,
|
HasCommittedScenes = data.HasCommittedScenes,
|
||||||
@ -81,6 +89,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||||||
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
|
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
|
||||||
{
|
{
|
||||||
|
DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(candidate.DisplayName, candidate.ExistingCharacterID),
|
||||||
Key = candidate.Key,
|
Key = candidate.Key,
|
||||||
CharacterName = candidate.DisplayName,
|
CharacterName = candidate.DisplayName,
|
||||||
ImportName = candidate.DisplayName,
|
ImportName = candidate.DisplayName,
|
||||||
@ -90,6 +99,8 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
||||||
ExistingCharacterID = candidate.ExistingCharacterID,
|
ExistingCharacterID = candidate.ExistingCharacterID,
|
||||||
ExistingCharacterName = candidate.ExistingCharacterName,
|
ExistingCharacterName = candidate.ExistingCharacterName,
|
||||||
|
SelectedExistingCharacterID = candidate.ExistingCharacterID,
|
||||||
|
ExistingCharacterOptions = existingOptions,
|
||||||
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(candidate.Key)
|
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(candidate.Key)
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
@ -210,7 +221,8 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
||||||
var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
|
var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
|
||||||
var matchedExistingId = linkExisting
|
var matchedExistingId = linkExisting
|
||||||
? candidate.ExistingCharacterID
|
? existingIndex.Find(choice.ExistingCharacterID)?.CharacterID
|
||||||
|
?? candidate.ExistingCharacterID
|
||||||
?? existingIndex.Find(importName)?.CharacterID
|
?? existingIndex.Find(importName)?.CharacterID
|
||||||
?? existingIndex.Find(candidate.DisplayName)?.CharacterID
|
?? existingIndex.Find(candidate.DisplayName)?.CharacterID
|
||||||
: null;
|
: null;
|
||||||
@ -498,6 +510,11 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances);
|
AddMention(groups, existingIndex, importedScene, parsed, character, existingAppearances);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var appearance in parsed.CharacterAppearance ?? [])
|
||||||
|
{
|
||||||
|
AddCharacterAppearanceAliases(groups, existingIndex, importedScene, parsed, appearance, existingAppearances);
|
||||||
|
}
|
||||||
|
|
||||||
var povName = CleanCharacterName(parsed.PointOfView?.CharacterName);
|
var povName = CleanCharacterName(parsed.PointOfView?.CharacterName);
|
||||||
if (IsCharacterNameCandidate(povName))
|
if (IsCharacterNameCandidate(povName))
|
||||||
{
|
{
|
||||||
@ -537,6 +554,31 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AddCharacterAppearanceAliases(
|
||||||
|
Dictionary<string, CharacterCandidate> groups,
|
||||||
|
CharacterIndex existingIndex,
|
||||||
|
Scene importedScene,
|
||||||
|
SceneIntelligenceScene parsed,
|
||||||
|
SceneIntelligenceCharacterAppearance appearance,
|
||||||
|
IReadOnlyList<SceneCharacter> existingAppearances)
|
||||||
|
{
|
||||||
|
var canonicalName = CleanCharacterName(appearance.CanonicalName);
|
||||||
|
if (!IsCharacterNameCandidate(canonicalName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter
|
||||||
|
{
|
||||||
|
Name = canonicalName,
|
||||||
|
RoleInScene = appearance.RoleOrOccupation,
|
||||||
|
MentionedOnly = false,
|
||||||
|
Aliases = appearance.Aliases,
|
||||||
|
Confidence = appearance.Confidence,
|
||||||
|
Notes = appearance.Evidence
|
||||||
|
}, existingAppearances);
|
||||||
|
}
|
||||||
|
|
||||||
private static void AddObservedName(
|
private static void AddObservedName(
|
||||||
Dictionary<string, CharacterCandidate> groups,
|
Dictionary<string, CharacterCandidate> groups,
|
||||||
CharacterIndex existingIndex,
|
CharacterIndex existingIndex,
|
||||||
@ -570,6 +612,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
var index = new CharacterIndex();
|
var index = new CharacterIndex();
|
||||||
foreach (var character in await characters.ListCharactersAsync(projectId))
|
foreach (var character in await characters.ListCharactersAsync(projectId))
|
||||||
{
|
{
|
||||||
|
index.AddCharacter(character.CharacterID, character.CharacterName);
|
||||||
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
|
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
|
||||||
index.Add(FirstName(character.CharacterName), character.CharacterID, character.CharacterName);
|
index.Add(FirstName(character.CharacterName), character.CharacterID, character.CharacterName);
|
||||||
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
|
index.Add(character.ShortName, character.CharacterID, character.CharacterName);
|
||||||
@ -943,8 +986,21 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
private sealed class CharacterIndex
|
private sealed class CharacterIndex
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
|
private readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly Dictionary<int, CharacterMatch> byId = new();
|
||||||
private readonly List<CharacterIndexRow> rows = [];
|
private readonly List<CharacterIndexRow> rows = [];
|
||||||
|
private readonly List<CharacterIndexCharacter> characters = [];
|
||||||
public IReadOnlyList<CharacterIndexRow> Rows => rows;
|
public IReadOnlyList<CharacterIndexRow> Rows => rows;
|
||||||
|
public IReadOnlyList<CharacterIndexCharacter> Characters => characters;
|
||||||
|
|
||||||
|
public void AddCharacter(int characterId, string characterName)
|
||||||
|
{
|
||||||
|
var match = new CharacterMatch(characterId, characterName);
|
||||||
|
byId.TryAdd(characterId, match);
|
||||||
|
if (!characters.Any(character => character.CharacterID == characterId))
|
||||||
|
{
|
||||||
|
characters.Add(new CharacterIndexCharacter(characterId, characterName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Add(string? name, int characterId, string characterName)
|
public void Add(string? name, int characterId, string characterName)
|
||||||
{
|
{
|
||||||
@ -958,10 +1014,14 @@ public sealed class StoryIntelligenceCharacterImportService(
|
|||||||
|
|
||||||
public CharacterMatch? Find(string name)
|
public CharacterMatch? Find(string name)
|
||||||
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
|
=> byName.TryGetValue(Normalise(name), out var match) ? match : null;
|
||||||
|
|
||||||
|
public CharacterMatch? Find(int? characterId)
|
||||||
|
=> characterId.HasValue && byId.TryGetValue(characterId.Value, out var match) ? match : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record CharacterMatch(int CharacterID, string CharacterName);
|
private sealed record CharacterMatch(int CharacterID, string CharacterName);
|
||||||
private sealed record CharacterIndexRow(string MatchName, string CharacterName);
|
private sealed record CharacterIndexRow(string MatchName, string CharacterName);
|
||||||
|
private sealed record CharacterIndexCharacter(int CharacterID, string CharacterName);
|
||||||
|
|
||||||
private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName)
|
private sealed class CharacterCandidate(string key, string displayName, int? existingCharacterId, string? existingCharacterName)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using PlotLine.ViewModels;
|
||||||
|
|
||||||
|
namespace PlotLine.Services;
|
||||||
|
|
||||||
|
public static class StoryIntelligenceCharacterReviewDefaults
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> GenericFamilyReferences = CreateSet(
|
||||||
|
"mum", "mom", "mam", "mother", "dad", "father", "brother", "sister", "grandmother", "grandfather",
|
||||||
|
"nan", "nanny", "grandma", "grandad", "granddad", "aunt", "auntie", "uncle", "cousin");
|
||||||
|
|
||||||
|
private static readonly Regex ContextualIndefiniteReference = new(
|
||||||
|
@"^(one of (them|his mates|her mates|their mates|the men|the women|the boys|the girls)|his mate|her mate|their mate|his friend|her friend|their friend)$",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public static string DetermineAction(string? candidateName, int? proposedExistingCharacterId)
|
||||||
|
{
|
||||||
|
if (proposedExistingCharacterId.HasValue)
|
||||||
|
{
|
||||||
|
return StoryIntelligenceCharacterImportActions.LinkExisting;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsGenericContextualReference(candidateName))
|
||||||
|
{
|
||||||
|
return StoryIntelligenceCharacterImportActions.Ignore;
|
||||||
|
}
|
||||||
|
|
||||||
|
return StoryIntelligenceCharacterImportActions.CreateNew;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsGenericContextualReference(string? candidateName)
|
||||||
|
{
|
||||||
|
var normalised = Normalise(candidateName);
|
||||||
|
if (string.IsNullOrWhiteSpace(normalised))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GenericFamilyReferences.Contains(normalised)
|
||||||
|
|| ContextualIndefiniteReference.IsMatch(normalised);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalise(string? value)
|
||||||
|
{
|
||||||
|
var cleaned = Regex.Replace(value ?? string.Empty, @"[^\p{L}\p{N}' ]+", " ");
|
||||||
|
cleaned = cleaned.Replace("’", "'", StringComparison.Ordinal);
|
||||||
|
return string.Join(' ', cleaned.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> CreateSet(params string[] values)
|
||||||
|
=> values.Select(Normalise).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@ -209,6 +209,7 @@ public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
|
|||||||
public string Key { get; init; } = string.Empty;
|
public string Key { get; init; } = string.Empty;
|
||||||
public string CharacterName { get; init; } = string.Empty;
|
public string CharacterName { get; init; } = string.Empty;
|
||||||
public string ImportName { get; init; } = string.Empty;
|
public string ImportName { get; init; } = string.Empty;
|
||||||
|
public string DefaultAction { get; init; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
||||||
public int AppearsInScenes { get; init; }
|
public int AppearsInScenes { get; init; }
|
||||||
public string Confidence { get; init; } = "Unknown";
|
public string Confidence { get; init; } = "Unknown";
|
||||||
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
|
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
|
||||||
@ -216,10 +217,19 @@ public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
|
|||||||
public string? NameIntelligenceWarning { get; init; }
|
public string? NameIntelligenceWarning { get; init; }
|
||||||
public int? ExistingCharacterID { get; init; }
|
public int? ExistingCharacterID { get; init; }
|
||||||
public string? ExistingCharacterName { get; init; }
|
public string? ExistingCharacterName { get; init; }
|
||||||
|
public int? SelectedExistingCharacterID { get; init; }
|
||||||
|
public IReadOnlyList<StoryIntelligenceCharacterExistingOptionViewModel> ExistingCharacterOptions { get; init; } = [];
|
||||||
public bool IsExistingMatch => ExistingCharacterID.HasValue;
|
public bool IsExistingMatch => ExistingCharacterID.HasValue;
|
||||||
|
public bool CanLinkExisting => ExistingCharacterOptions.Count > 0;
|
||||||
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
|
public string ActionLabel => IsExistingMatch ? "Link existing" : "Create";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class StoryIntelligenceCharacterExistingOptionViewModel
|
||||||
|
{
|
||||||
|
public int CharacterID { get; init; }
|
||||||
|
public string CharacterName { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class StoryIntelligenceCharacterImportForm
|
public sealed class StoryIntelligenceCharacterImportForm
|
||||||
{
|
{
|
||||||
public Guid BatchID { get; set; }
|
public Guid BatchID { get; set; }
|
||||||
@ -231,6 +241,7 @@ public sealed class StoryIntelligenceCharacterImportChoiceForm
|
|||||||
public string Key { get; set; } = string.Empty;
|
public string Key { get; set; } = string.Empty;
|
||||||
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
||||||
public string? ImportName { get; set; }
|
public string? ImportName { get; set; }
|
||||||
|
public int? ExistingCharacterID { get; set; }
|
||||||
public string? AliasTargetKey { get; set; }
|
public string? AliasTargetKey { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -92,23 +92,41 @@
|
|||||||
<fieldset class="story-character-actions">
|
<fieldset class="story-character-actions">
|
||||||
<legend>Decision</legend>
|
<legend>Decision</legend>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.CreateNew" checked data-character-action />
|
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.CreateNew" @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.CreateNew) ? "checked" : string.Empty) data-character-action />
|
||||||
Create new character
|
Create new character
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.LinkExisting" data-character-action @(candidate.IsExistingMatch ? string.Empty : "disabled") />
|
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.LinkExisting" data-character-action @(candidate.CanLinkExisting ? string.Empty : "disabled") @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.LinkExisting) ? "checked" : string.Empty) />
|
||||||
Link existing character
|
Link existing character
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Alias" data-character-action />
|
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Alias" @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.Alias) ? "checked" : string.Empty) data-character-action />
|
||||||
Alias of another character
|
Alias of another character
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" data-character-action />
|
<input type="radio" name="Characters[@i].Action" value="@StoryIntelligenceCharacterImportActions.Ignore" @(IsDefault(candidate, StoryIntelligenceCharacterImportActions.Ignore) ? "checked" : string.Empty) data-character-action />
|
||||||
Ignore
|
Ignore
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<div data-character-existing-panel hidden>
|
||||||
|
<label class="form-label" for="character-existing-@i">Existing character</label>
|
||||||
|
<select id="character-existing-@i" class="form-select" name="Characters[@i].ExistingCharacterID" data-character-existing-target>
|
||||||
|
<option value="">Choose character...</option>
|
||||||
|
@foreach (var existing in candidate.ExistingCharacterOptions)
|
||||||
|
{
|
||||||
|
if (candidate.SelectedExistingCharacterID == existing.CharacterID)
|
||||||
|
{
|
||||||
|
<option value="@existing.CharacterID" selected>@existing.CharacterName</option>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<option value="@existing.CharacterID">@existing.CharacterName</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div data-character-import-name-panel>
|
<div data-character-import-name-panel>
|
||||||
<label class="form-label" for="character-import-name-@i">Import name</label>
|
<label class="form-label" for="character-import-name-@i">Import name</label>
|
||||||
<input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" />
|
<input id="character-import-name-@i" class="form-control" name="Characters[@i].ImportName" value="@candidate.ImportName" />
|
||||||
@ -183,7 +201,9 @@
|
|||||||
const action = actionFor(card);
|
const action = actionFor(card);
|
||||||
const namePanel = card.querySelector("[data-character-import-name-panel]");
|
const namePanel = card.querySelector("[data-character-import-name-panel]");
|
||||||
const aliasPanel = card.querySelector("[data-character-alias-panel]");
|
const aliasPanel = card.querySelector("[data-character-alias-panel]");
|
||||||
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.Alias";
|
const existingPanel = card.querySelector("[data-character-existing-panel]");
|
||||||
|
if (namePanel) namePanel.hidden = action === "@StoryIntelligenceCharacterImportActions.Ignore" || action === "@StoryIntelligenceCharacterImportActions.Alias" || action === "@StoryIntelligenceCharacterImportActions.LinkExisting";
|
||||||
|
if (existingPanel) existingPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.LinkExisting";
|
||||||
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.Alias";
|
if (aliasPanel) aliasPanel.hidden = action !== "@StoryIntelligenceCharacterImportActions.Alias";
|
||||||
updateAliasTargets();
|
updateAliasTargets();
|
||||||
};
|
};
|
||||||
@ -212,6 +232,9 @@
|
|||||||
@functions {
|
@functions {
|
||||||
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
|
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "Not detected" : value;
|
||||||
|
|
||||||
|
private static bool IsDefault(StoryIntelligenceCharacterReviewCandidateViewModel candidate, string action)
|
||||||
|
=> string.Equals(candidate.DefaultAction, action, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
|
private static Microsoft.AspNetCore.Html.IHtmlContent FutureStage(string label)
|
||||||
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>");
|
=> new Microsoft.AspNetCore.Html.HtmlString($"<article class=\"story-future-stage\"><strong>{System.Net.WebUtility.HtmlEncode(label)}</strong><span>Coming Soon</span></article>");
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user