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 repository uses stored procedures", NameIntelligenceRepositoryUsesStoredProcedures),
|
||||
("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),
|
||||
("StoryPosition rejects incomplete or invalid runtime scene positions", StoryPositionRejectsInvalidRuntimeScenePositions),
|
||||
("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.");
|
||||
}
|
||||
|
||||
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)
|
||||
=> 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 existingIndex = await BuildCharacterIndexAsync(batch.ProjectID);
|
||||
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
|
||||
{
|
||||
HasCommittedScenes = data.HasCommittedScenes,
|
||||
@ -81,6 +89,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
IsComplete = data.HasCommittedScenes && visibleCandidates.Count == 0,
|
||||
Candidates = visibleCandidates.Select(candidate => new StoryIntelligenceCharacterReviewCandidateViewModel
|
||||
{
|
||||
DefaultAction = StoryIntelligenceCharacterReviewDefaults.DetermineAction(candidate.DisplayName, candidate.ExistingCharacterID),
|
||||
Key = candidate.Key,
|
||||
CharacterName = candidate.DisplayName,
|
||||
ImportName = candidate.DisplayName,
|
||||
@ -90,6 +99,8 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
ExampleFirstAppearance = candidate.FirstAppearanceNote,
|
||||
ExistingCharacterID = candidate.ExistingCharacterID,
|
||||
ExistingCharacterName = candidate.ExistingCharacterName,
|
||||
SelectedExistingCharacterID = candidate.ExistingCharacterID,
|
||||
ExistingCharacterOptions = existingOptions,
|
||||
NameIntelligenceWarning = nameWarnings.GetValueOrDefault(candidate.Key)
|
||||
}).ToList()
|
||||
};
|
||||
@ -210,7 +221,8 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
var importName = string.IsNullOrWhiteSpace(requestedName) ? candidate.DisplayName : requestedName;
|
||||
var linkExisting = string.Equals(choice.Action, StoryIntelligenceCharacterImportActions.LinkExisting, StringComparison.OrdinalIgnoreCase);
|
||||
var matchedExistingId = linkExisting
|
||||
? candidate.ExistingCharacterID
|
||||
? existingIndex.Find(choice.ExistingCharacterID)?.CharacterID
|
||||
?? candidate.ExistingCharacterID
|
||||
?? existingIndex.Find(importName)?.CharacterID
|
||||
?? existingIndex.Find(candidate.DisplayName)?.CharacterID
|
||||
: null;
|
||||
@ -498,6 +510,11 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
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);
|
||||
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(
|
||||
Dictionary<string, CharacterCandidate> groups,
|
||||
CharacterIndex existingIndex,
|
||||
@ -570,6 +612,7 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
var index = new CharacterIndex();
|
||||
foreach (var character in await characters.ListCharactersAsync(projectId))
|
||||
{
|
||||
index.AddCharacter(character.CharacterID, character.CharacterName);
|
||||
index.Add(character.CharacterName, character.CharacterID, character.CharacterName);
|
||||
index.Add(FirstName(character.CharacterName), 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 readonly Dictionary<string, CharacterMatch> byName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<int, CharacterMatch> byId = new();
|
||||
private readonly List<CharacterIndexRow> rows = [];
|
||||
private readonly List<CharacterIndexCharacter> characters = [];
|
||||
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)
|
||||
{
|
||||
@ -958,10 +1014,14 @@ public sealed class StoryIntelligenceCharacterImportService(
|
||||
|
||||
public CharacterMatch? Find(string name)
|
||||
=> 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 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)
|
||||
{
|
||||
|
||||
@ -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 CharacterName { get; init; } = string.Empty;
|
||||
public string ImportName { get; init; } = string.Empty;
|
||||
public string DefaultAction { get; init; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
||||
public int AppearsInScenes { get; init; }
|
||||
public string Confidence { get; init; } = "Unknown";
|
||||
public IReadOnlyList<string> PossibleAliases { get; init; } = [];
|
||||
@ -216,10 +217,19 @@ public sealed class StoryIntelligenceCharacterReviewCandidateViewModel
|
||||
public string? NameIntelligenceWarning { get; init; }
|
||||
public int? ExistingCharacterID { 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 CanLinkExisting => ExistingCharacterOptions.Count > 0;
|
||||
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 Guid BatchID { get; set; }
|
||||
@ -231,6 +241,7 @@ public sealed class StoryIntelligenceCharacterImportChoiceForm
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Action { get; set; } = StoryIntelligenceCharacterImportActions.CreateNew;
|
||||
public string? ImportName { get; set; }
|
||||
public int? ExistingCharacterID { get; set; }
|
||||
public string? AliasTargetKey { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@ -92,23 +92,41 @@
|
||||
<fieldset class="story-character-actions">
|
||||
<legend>Decision</legend>
|
||||
<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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</label>
|
||||
</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>
|
||||
<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" />
|
||||
@ -183,7 +201,9 @@
|
||||
const action = actionFor(card);
|
||||
const namePanel = card.querySelector("[data-character-import-name-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";
|
||||
updateAliasTargets();
|
||||
};
|
||||
@ -212,6 +232,9 @@
|
||||
@functions {
|
||||
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)
|
||||
=> 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