From 305446126ead421ee9f05c33dff53fe94c3829be Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Tue, 2 Jun 2026 19:42:46 +0100 Subject: [PATCH] Import page updates. --- PlotLine/Controllers/ImportsController.cs | 2 + PlotLine/Data/ImportRepository.cs | 54 +++++++++++++++++++++-- PlotLine/Services/CoreServices.cs | 43 +++++++++++++++++- PlotLine/Services/ImportServices.cs | 12 +++++ PlotLine/ViewModels/ImportViewModels.cs | 2 +- PlotLine/Views/Imports/Index.cshtml | 54 ++++++++++++++++++++--- PlotLine/wwwroot/css/site.css | 2 +- 7 files changed, 157 insertions(+), 12 deletions(-) diff --git a/PlotLine/Controllers/ImportsController.cs b/PlotLine/Controllers/ImportsController.cs index d48961a..618644c 100644 --- a/PlotLine/Controllers/ImportsController.cs +++ b/PlotLine/Controllers/ImportsController.cs @@ -20,6 +20,7 @@ public sealed class ImportsController(IImportService imports) : Controller public async Task 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); } diff --git a/PlotLine/Data/ImportRepository.cs b/PlotLine/Data/ImportRepository.cs index 4725178..fe481ea 100644 --- a/PlotLine/Data/ImportRepository.cs +++ b/PlotLine/Data/ImportRepository.cs @@ -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 GetSkippedOptionalFields(PlotLineImportPackage package) { var skipped = new List(); @@ -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); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 0252e32..711550a 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -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 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 diff --git a/PlotLine/Services/ImportServices.cs b/PlotLine/Services/ImportServices.cs index e24210e..3106e7d 100644 --- a/PlotLine/Services/ImportServices.cs +++ b/PlotLine/Services/ImportServices.cs @@ -769,6 +769,11 @@ public sealed class ImportService(IImportRepository imports, ILogger 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 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); diff --git a/PlotLine/ViewModels/ImportViewModels.cs b/PlotLine/ViewModels/ImportViewModels.cs index 5866eee..933758f 100644 --- a/PlotLine/ViewModels/ImportViewModels.cs +++ b/PlotLine/ViewModels/ImportViewModels.cs @@ -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." } diff --git a/PlotLine/Views/Imports/Index.cshtml b/PlotLine/Views/Imports/Index.cshtml index 2d1dffd..e46f294 100644 --- a/PlotLine/Views/Imports/Index.cshtml +++ b/PlotLine/Views/Imports/Index.cshtml @@ -42,6 +42,7 @@
Uploading a file replaces the pasted text for this preview.
+
@@ -139,7 +140,8 @@
- + @* Carry forward the active textarea JSON for secondary forms. The visible textarea is authoritative. *@ +
@@ -347,7 +349,8 @@ }
- + @* Carry forward the active textarea JSON for dry run/import. The visible textarea is authoritative. *@ + @if (preview.Validation.Warnings.Any()) {
@@ -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); }); })(); diff --git a/PlotLine/wwwroot/css/site.css b/PlotLine/wwwroot/css/site.css index 9a4ef73..5d1fece 100644 --- a/PlotLine/wwwroot/css/site.css +++ b/PlotLine/wwwroot/css/site.css @@ -171,7 +171,7 @@ td.text-end { .import-panel, .import-preview-section { - max-width: 1180px; + max-width: none; } .import-json-input {