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

View File

@ -97,9 +97,8 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
EyeColour = (string?)null, EyeColour = (string?)null,
DefaultDescription = CombineText( DefaultDescription = CombineText(
character.Description, character.Description,
MetadataLine("Role", character.Role),
MetadataLine("Importance", character.Importance?.ToString()),
character.Notes, character.Notes,
CharacterImportMetadata(character),
ImportMarker) ImportMarker)
}, },
transaction, transaction,
@ -659,6 +658,12 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
MetadataLine("Package description", package.Description)) ?? ImportMarker; 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) private static IReadOnlyList<string> GetSkippedOptionalFields(PlotLineImportPackage package)
{ {
var skipped = new List<string>(); var skipped = new List<string>();
@ -964,9 +969,14 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
var roleName = normalized.Equals("POV", StringComparison.OrdinalIgnoreCase) var roleName = normalized.Equals("POV", StringComparison.OrdinalIgnoreCase)
? "POV Character" ? "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" ? "Main Participant"
: normalized.Equals("Supporting", StringComparison.OrdinalIgnoreCase) : normalized.Equals("Supporting", StringComparison.OrdinalIgnoreCase)
|| normalized.Contains("secondary", StringComparison.OrdinalIgnoreCase)
? "Supporting Participant" ? "Supporting Participant"
: normalized.Equals("Mentioned", StringComparison.OrdinalIgnoreCase) : normalized.Equals("Mentioned", StringComparison.OrdinalIgnoreCase)
? "Mentioned Only" ? "Mentioned Only"
@ -1049,6 +1059,44 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
: lookupIds.ThreadEventTypeIds.Values.First(); : 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) private static int ResolveSceneDependencyTypeId(ImportLookupIds lookupIds, string? dependencyType)
{ {
var normalized = Clean(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())); characterGroup => characterGroup.GroupBy(x => x.SceneID).ToDictionary(sceneGroup => sceneGroup.Key, sceneGroup => sceneGroup.ToList()));
return characters 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 => .Select(character =>
{ {
appearancesByCharacterAndScene.TryGetValue(character.CharacterID, out var sceneAppearances); appearancesByCharacterAndScene.TryGetValue(character.CharacterID, out var sceneAppearances);
@ -1366,6 +1370,43 @@ public sealed class TimelineService(
}; };
}).ToList(); }).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 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) 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 foreach (var group in package.Characters
.Where(x => !string.IsNullOrWhiteSpace(x.Name)) .Where(x => !string.IsNullOrWhiteSpace(x.Name))
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase) .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) 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 }) if (model.JsonFile is { Length: > 0 })
{ {
using var reader = new StreamReader(model.JsonFile.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); using var reader = new StreamReader(model.JsonFile.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

View File

@ -43,7 +43,7 @@ public sealed class ImportIndexViewModel
} }
}, },
"characters": [ "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": "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": "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." } { "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> <label asp-for="JsonFile" class="form-label">Upload JSON file</label>
<input asp-for="JsonFile" class="form-control" type="file" accept=".json,application/json" /> <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">Uploading a file replaces the pasted text for this preview.</div>
<div class="form-text" data-upload-status></div>
</div> </div>
<div class="col-12"> <div class="col-12">
<label asp-for="JsonText" class="form-label">JSON package</label> <label asp-for="JsonText" class="form-label">JSON package</label>
@ -139,7 +140,8 @@
<div class="button-row mb-3"> <div class="button-row mb-3">
<form asp-action="ValidationReport" method="post"> <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> <button class="btn btn-outline-secondary btn-sm" type="submit">Download validation report</button>
</form> </form>
</div> </div>
@ -347,7 +349,8 @@
} }
<form asp-action="Import" method="post" class="import-confirm-form"> <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()) @if (preview.Validation.Warnings.Any())
{ {
<div class="form-check import-duplicate-confirm"> <div class="form-check import-duplicate-confirm">
@ -387,10 +390,49 @@
const button = document.querySelector("[data-copy-sample]"); const button = document.querySelector("[data-copy-sample]");
const textarea = document.querySelector("#JsonText"); const textarea = document.querySelector("#JsonText");
const sample = document.querySelector("#importSampleJson"); const sample = document.querySelector("#importSampleJson");
if (!button || !textarea || !sample) return; const fileInput = document.querySelector("#JsonFile");
button.addEventListener("click", () => { const uploadStatus = document.querySelector("[data-upload-status]");
textarea.value = JSON.parse(sample.textContent); const syncHiddenJson = () => {
textarea.focus(); 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> </script>

View File

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