Import page updates.

This commit is contained in:
Nick Beckley 2026-06-02 19:42:46 +01:00
parent ef5688bb88
commit 305446126e
7 changed files with 157 additions and 12 deletions

View File

@ -20,6 +20,7 @@ public sealed class ImportsController(IImportService imports) : Controller
public async Task<IActionResult> Preview(ImportIndexViewModel model)
{
model = await imports.PreviewAsync(model);
ModelState.Clear();
return View("Index", model);
}
@ -36,6 +37,7 @@ public sealed class ImportsController(IImportService imports) : Controller
model.Preview ??= (await imports.PreviewAsync(model)).Preview;
model.StatusMessage = result.Message;
model.TechnicalDetail = result.TechnicalDetail;
ModelState.Clear();
return View("Index", model);
}

View File

@ -97,9 +97,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
EyeColour = (string?)null,
DefaultDescription = CombineText(
character.Description,
MetadataLine("Role", character.Role),
MetadataLine("Importance", character.Importance?.ToString()),
character.Notes,
CharacterImportMetadata(character),
ImportMarker)
},
transaction,
@ -659,6 +658,12 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
MetadataLine("Package description", package.Description)) ?? ImportMarker;
}
private static string? CharacterImportMetadata(ImportCharacterDto character)
{
var role = NormalizeCharacterRole(character.Role, character.Importance);
return role is null ? null : $"Imported character classification: {role}";
}
private static IReadOnlyList<string> GetSkippedOptionalFields(PlotLineImportPackage package)
{
var skipped = new List<string>();
@ -964,9 +969,14 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
var roleName = normalized.Equals("POV", StringComparison.OrdinalIgnoreCase)
? "POV Character"
: normalized.Equals("Present", StringComparison.OrdinalIgnoreCase)
: normalized.Contains("protagonist", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("main", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("major", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("lead", StringComparison.OrdinalIgnoreCase)
|| normalized.Equals("Present", StringComparison.OrdinalIgnoreCase)
? "Main Participant"
: normalized.Equals("Supporting", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("secondary", StringComparison.OrdinalIgnoreCase)
? "Supporting Participant"
: normalized.Equals("Mentioned", StringComparison.OrdinalIgnoreCase)
? "Mentioned Only"
@ -1049,6 +1059,44 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
: lookupIds.ThreadEventTypeIds.Values.First();
}
private static string? NormalizeCharacterRole(string? role, int? importance)
{
var normalized = Clean(role).ToLowerInvariant();
if (normalized.Contains("protagonist", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("main", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("major", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("lead", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("antagonist", StringComparison.OrdinalIgnoreCase))
{
return "Major";
}
if (normalized.Contains("minor", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("background", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("cameo", StringComparison.OrdinalIgnoreCase))
{
return "Minor";
}
if (normalized.Contains("support", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("secondary", StringComparison.OrdinalIgnoreCase))
{
return "Supporting";
}
if (importance >= 8)
{
return "Major";
}
if (importance >= 5)
{
return "Supporting";
}
return importance.HasValue ? "Minor" : null;
}
private static int ResolveSceneDependencyTypeId(ImportLookupIds lookupIds, string? dependencyType)
{
var normalized = Clean(dependencyType);

View File

@ -1349,7 +1349,11 @@ public sealed class TimelineService(
characterGroup => characterGroup.GroupBy(x => x.SceneID).ToDictionary(sceneGroup => sceneGroup.Key, sceneGroup => sceneGroup.ToList()));
return characters
.OrderBy(x => x.CharacterName)
// Character has no global category/importance column. Timeline grouping uses lane order:
// the first six lanes render as "Major characters", so imported classification metadata
// and strong scene roles should sort likely main characters ahead of supporting cast.
.OrderByDescending(character => CharacterLanePriority(character, appearancesByCharacterAndScene.TryGetValue(character.CharacterID, out var sceneAppearances) ? sceneAppearances.Values.SelectMany(x => x) : []))
.ThenBy(x => x.CharacterName)
.Select(character =>
{
appearancesByCharacterAndScene.TryGetValue(character.CharacterID, out var sceneAppearances);
@ -1366,6 +1370,43 @@ public sealed class TimelineService(
};
}).ToList();
}
private static int CharacterLanePriority(Character character, IEnumerable<SceneCharacter> appearances)
{
var description = character.DefaultDescription ?? string.Empty;
var priority = 0;
if (description.Contains("Imported character classification: Major", StringComparison.OrdinalIgnoreCase))
{
priority += 1_000;
}
else if (description.Contains("Imported character classification: Supporting", StringComparison.OrdinalIgnoreCase))
{
priority += 400;
}
else if (description.Contains("Imported character classification: Minor", StringComparison.OrdinalIgnoreCase))
{
priority -= 100;
}
foreach (var appearance in appearances)
{
if (string.Equals(appearance.RoleInSceneTypeName, "POV Character", StringComparison.OrdinalIgnoreCase))
{
priority += 80;
}
else if (string.Equals(appearance.RoleInSceneTypeName, "Main Participant", StringComparison.OrdinalIgnoreCase))
{
priority += 35;
}
else if (string.Equals(appearance.RoleInSceneTypeName, "Supporting Participant", StringComparison.OrdinalIgnoreCase))
{
priority += 10;
}
}
return priority;
}
}
public sealed class PlotService(IProjectRepository projects, IBookRepository books, IPlotRepository plots) : IPlotService

View File

@ -769,6 +769,11 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
private static void ValidateCharacters(PlotLineImportPackage package, ImportValidationResult result)
{
if (package.Characters.Any(character => character.Importance.HasValue || !string.IsNullOrWhiteSpace(character.Role)))
{
result.Warnings.Add("Character importance is not stored in a dedicated character field; the importer uses role/importance for timeline grouping where supported and preserves a clean classification note.");
}
foreach (var group in package.Characters
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
@ -1341,6 +1346,13 @@ public sealed class ImportService(IImportRepository imports, ILogger<ImportServi
private static async Task<string> ReadJsonTextAsync(ImportIndexViewModel model)
{
if (model.JsonFile is { Length: > 0 }
&& !string.IsNullOrWhiteSpace(model.JsonText)
&& !string.Equals(model.JsonText.Trim(), ImportIndexViewModel.SampleJson.Trim(), StringComparison.Ordinal))
{
return model.JsonText;
}
if (model.JsonFile is { Length: > 0 })
{
using var reader = new StreamReader(model.JsonFile.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

View File

@ -43,7 +43,7 @@ public sealed class ImportIndexViewModel
}
},
"characters": [
{ "name": "Mara", "displayName": "Mara Vale", "shortName": "Mara", "role": "Protagonist", "importance": 10, "description": "Courier drawn into the hidden city.", "notes": "Primary POV for book one." },
{ "name": "Mara", "displayName": "Mara Vale", "shortName": "Mara", "role": "Main protagonist", "importance": 10, "description": "Courier drawn into the hidden city.", "notes": "Primary POV for book one." },
{ "name": "Ilen", "displayName": "Ilen Or", "shortName": "Ilen", "role": "Guide", "importance": 8, "description": "Watchman with partial knowledge of the old routes.", "notes": "Carries secrets into book two." },
{ "name": "Sera", "displayName": "Sera Quill", "shortName": "Sera", "role": "Antagonist", "importance": 7, "description": "Rival mapmaker.", "notes": "Opposes the expedition." },
{ "name": "Tovin", "displayName": "Tovin Reed", "shortName": "Tovin", "role": "Supporting", "importance": 5, "description": "Mechanic and signal reader.", "notes": "Useful for technical scenes." }

View File

@ -42,6 +42,7 @@
<label asp-for="JsonFile" class="form-label">Upload JSON file</label>
<input asp-for="JsonFile" class="form-control" type="file" accept=".json,application/json" />
<div class="form-text">Uploading a file replaces the pasted text for this preview.</div>
<div class="form-text" data-upload-status></div>
</div>
<div class="col-12">
<label asp-for="JsonText" class="form-label">JSON package</label>
@ -139,7 +140,8 @@
<div class="button-row mb-3">
<form asp-action="ValidationReport" method="post">
<input asp-for="JsonText" type="hidden" />
@* Carry forward the active textarea JSON for secondary forms. The visible textarea is authoritative. *@
<input name="JsonText" type="hidden" value="@Model.JsonText" data-json-carry-forward />
<button class="btn btn-outline-secondary btn-sm" type="submit">Download validation report</button>
</form>
</div>
@ -347,7 +349,8 @@
}
<form asp-action="Import" method="post" class="import-confirm-form">
<input asp-for="JsonText" type="hidden" />
@* Carry forward the active textarea JSON for dry run/import. The visible textarea is authoritative. *@
<input name="JsonText" type="hidden" value="@Model.JsonText" data-json-carry-forward />
@if (preview.Validation.Warnings.Any())
{
<div class="form-check import-duplicate-confirm">
@ -387,10 +390,49 @@
const button = document.querySelector("[data-copy-sample]");
const textarea = document.querySelector("#JsonText");
const sample = document.querySelector("#importSampleJson");
if (!button || !textarea || !sample) return;
button.addEventListener("click", () => {
textarea.value = JSON.parse(sample.textContent);
textarea.focus();
const fileInput = document.querySelector("#JsonFile");
const uploadStatus = document.querySelector("[data-upload-status]");
const syncHiddenJson = () => {
document.querySelectorAll("[data-json-carry-forward]").forEach((input) => {
input.value = textarea?.value || "";
});
};
if (button && textarea && sample) {
button.addEventListener("click", () => {
textarea.value = JSON.parse(sample.textContent);
syncHiddenJson();
if (uploadStatus) uploadStatus.textContent = "Sample JSON loaded into the package textarea.";
textarea.focus();
});
}
fileInput?.addEventListener("change", () => {
const file = fileInput.files && fileInput.files[0];
if (!file || !textarea) return;
if (file.size === 0) {
if (uploadStatus) uploadStatus.textContent = `${file.name} is empty. Choose a JSON file with content.`;
fileInput.value = "";
return;
}
const reader = new FileReader();
reader.onload = () => {
textarea.value = reader.result || "";
syncHiddenJson();
if (uploadStatus) uploadStatus.textContent = `Loaded JSON from ${file.name}.`;
};
reader.onerror = () => {
if (uploadStatus) uploadStatus.textContent = `Could not read ${file.name}. Try choosing the file again.`;
fileInput.value = "";
};
reader.readAsText(file, "UTF-8");
});
textarea?.addEventListener("input", syncHiddenJson);
document.querySelectorAll("form").forEach((form) => {
form.addEventListener("submit", syncHiddenJson);
});
})();
</script>

View File

@ -171,7 +171,7 @@ td.text-end {
.import-panel,
.import-preview-section {
max-width: 1180px;
max-width: none;
}
.import-json-input {