Import page updates.
This commit is contained in:
parent
ef5688bb88
commit
305446126e
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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." }
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -171,7 +171,7 @@ td.text-end {
|
||||
|
||||
.import-panel,
|
||||
.import-preview-section {
|
||||
max-width: 1180px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.import-json-input {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user