| | | 1 | | using System.Text.Json; |
| | | 2 | | using Chronicis.Client.Models; |
| | | 3 | | |
| | | 4 | | namespace Chronicis.Client.Services; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Generates a starter RenderDefinition from a sample JSON record. |
| | | 8 | | /// Uses heuristics to group fields, detect ability scores, and choose render hints. |
| | | 9 | | /// The output is a starting point for manual refinement. |
| | | 10 | | /// </summary> |
| | | 11 | | public static class RenderDefinitionGeneratorService |
| | | 12 | | { |
| | | 13 | | // Well-known metadata fields to hide |
| | 0 | 14 | | private static readonly HashSet<string> HiddenFields = new(StringComparer.OrdinalIgnoreCase) |
| | 0 | 15 | | { |
| | 0 | 16 | | "pk", "model", "document", "illustration", "url", "key", "slug", |
| | 0 | 17 | | "hover", "v2_converted_path", "img_main", "document__slug", |
| | 0 | 18 | | "document__title", "document__license_url", "document__url", |
| | 0 | 19 | | "page_no", "spell_list", "environments" |
| | 0 | 20 | | }; |
| | | 21 | | |
| | 0 | 22 | | private static readonly string[] TitleCandidates = { "name", "title" }; |
| | | 23 | | |
| | 0 | 24 | | private static readonly string[] AbilitySuffixes = |
| | 0 | 25 | | { "strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma" }; |
| | | 26 | | |
| | 0 | 27 | | private static readonly string[] AbilityLabels = |
| | 0 | 28 | | { "STR", "DEX", "CON", "INT", "WIS", "CHA" }; |
| | | 29 | | |
| | | 30 | | public static RenderDefinition Generate(JsonElement sample) |
| | | 31 | | { |
| | 0 | 32 | | var dataSource = sample; |
| | 0 | 33 | | if (sample.ValueKind == JsonValueKind.Object && |
| | 0 | 34 | | sample.TryGetProperty("fields", out var fields) && |
| | 0 | 35 | | fields.ValueKind == JsonValueKind.Object) |
| | | 36 | | { |
| | 0 | 37 | | dataSource = fields; |
| | | 38 | | } |
| | | 39 | | |
| | 0 | 40 | | if (dataSource.ValueKind != JsonValueKind.Object) |
| | 0 | 41 | | return CreateMinimal(); |
| | | 42 | | |
| | 0 | 43 | | var fieldInfos = new List<FieldInfo>(); |
| | 0 | 44 | | foreach (var prop in dataSource.EnumerateObject()) |
| | | 45 | | { |
| | 0 | 46 | | fieldInfos.Add(new FieldInfo |
| | 0 | 47 | | { |
| | 0 | 48 | | Name = prop.Name, |
| | 0 | 49 | | Value = prop.Value, |
| | 0 | 50 | | Kind = prop.Value.ValueKind, |
| | 0 | 51 | | IsNull = IsNullOrEmpty(prop.Value), |
| | 0 | 52 | | IsComplex = prop.Value.ValueKind == JsonValueKind.Object || |
| | 0 | 53 | | prop.Value.ValueKind == JsonValueKind.Array |
| | 0 | 54 | | }); |
| | | 55 | | } |
| | | 56 | | |
| | 0 | 57 | | var titleField = fieldInfos |
| | 0 | 58 | | .FirstOrDefault(f => TitleCandidates.Contains(f.Name, StringComparer.OrdinalIgnoreCase)) |
| | 0 | 59 | | ?.Name ?? "name"; |
| | | 60 | | |
| | 0 | 61 | | var hidden = new List<string>(); |
| | 0 | 62 | | var remaining = new List<FieldInfo>(); |
| | | 63 | | |
| | 0 | 64 | | foreach (var fi in fieldInfos) |
| | | 65 | | { |
| | 0 | 66 | | if (fi.Name.Equals(titleField, StringComparison.OrdinalIgnoreCase)) |
| | | 67 | | continue; |
| | 0 | 68 | | if (HiddenFields.Contains(fi.Name)) |
| | 0 | 69 | | hidden.Add(fi.Name); |
| | | 70 | | else |
| | 0 | 71 | | remaining.Add(fi); |
| | | 72 | | } |
| | | 73 | | |
| | 0 | 74 | | var prefixGroups = DetectPrefixGroups(remaining); |
| | 0 | 75 | | var groupedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 76 | | foreach (var group in prefixGroups) |
| | 0 | 77 | | foreach (var fi in group.Fields) |
| | 0 | 78 | | groupedNames.Add(fi.Name); |
| | | 79 | | |
| | 0 | 80 | | var ungrouped = remaining.Where(f => !groupedNames.Contains(f.Name)).ToList(); |
| | 0 | 81 | | var sections = new List<RenderSection>(); |
| | | 82 | | |
| | | 83 | | // Overview section |
| | 0 | 84 | | var overviewFields = ungrouped |
| | 0 | 85 | | .Where(f => !f.IsComplex) |
| | 0 | 86 | | .OrderBy(f => IsDescriptionField(f.Name) ? 1 : 0) |
| | 0 | 87 | | .ToList(); |
| | | 88 | | |
| | 0 | 89 | | if (overviewFields.Count > 0) |
| | | 90 | | { |
| | 0 | 91 | | sections.Add(new RenderSection |
| | 0 | 92 | | { |
| | 0 | 93 | | Label = "Overview", |
| | 0 | 94 | | Render = "fields", |
| | 0 | 95 | | Fields = overviewFields.Select(f => new RenderField |
| | 0 | 96 | | { |
| | 0 | 97 | | Path = f.Name, |
| | 0 | 98 | | Label = FormatFieldName(f.Name), |
| | 0 | 99 | | Render = IsDescriptionField(f.Name) ? "richtext" : "text" |
| | 0 | 100 | | }).ToList() |
| | 0 | 101 | | }); |
| | | 102 | | } |
| | | 103 | | |
| | | 104 | | // Prefix-grouped sections |
| | 0 | 105 | | foreach (var group in prefixGroups.OrderBy(g => g.Label)) |
| | | 106 | | { |
| | 0 | 107 | | var isAbilityScores = IsAbilityScoreGroup(group); |
| | 0 | 108 | | var allNull = group.Fields.All(f => f.IsNull); |
| | 0 | 109 | | var mostlyNull = group.Fields.Count(f => f.IsNull) > group.Fields.Count / 2; |
| | | 110 | | |
| | 0 | 111 | | if (isAbilityScores) |
| | | 112 | | { |
| | | 113 | | // Stat-row rendering for ability scores |
| | 0 | 114 | | sections.Add(new RenderSection |
| | 0 | 115 | | { |
| | 0 | 116 | | Label = "Ability Scores", |
| | 0 | 117 | | Render = "stat-row", |
| | 0 | 118 | | Fields = AbilitySuffixes.Select((suffix, i) => |
| | 0 | 119 | | { |
| | 0 | 120 | | var match = group.Fields.FirstOrDefault(f => |
| | 0 | 121 | | f.Name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); |
| | 0 | 122 | | return new RenderField |
| | 0 | 123 | | { |
| | 0 | 124 | | Path = match?.Name ?? $"ability_score_{suffix}", |
| | 0 | 125 | | Label = AbilityLabels[i] |
| | 0 | 126 | | }; |
| | 0 | 127 | | }).ToList() |
| | 0 | 128 | | }); |
| | | 129 | | } |
| | | 130 | | else |
| | | 131 | | { |
| | 0 | 132 | | sections.Add(new RenderSection |
| | 0 | 133 | | { |
| | 0 | 134 | | Label = FormatGroupLabel(group.Prefix), |
| | 0 | 135 | | Render = "fields", |
| | 0 | 136 | | Collapsed = mostlyNull, |
| | 0 | 137 | | Fields = group.Fields |
| | 0 | 138 | | .OrderBy(f => f.IsNull ? 1 : 0) |
| | 0 | 139 | | .Select(f => new RenderField |
| | 0 | 140 | | { |
| | 0 | 141 | | Path = f.Name, |
| | 0 | 142 | | Label = FormatFieldName(StripPrefix(f.Name, group.Prefix)) |
| | 0 | 143 | | }).ToList() |
| | 0 | 144 | | }); |
| | | 145 | | } |
| | | 146 | | } |
| | | 147 | | |
| | | 148 | | // Complex fields section (arrays/objects) |
| | 0 | 149 | | var complexFields = ungrouped.Where(f => f.IsComplex).ToList(); |
| | 0 | 150 | | if (complexFields.Count > 0) |
| | | 151 | | { |
| | 0 | 152 | | sections.Add(new RenderSection |
| | 0 | 153 | | { |
| | 0 | 154 | | Label = "Additional Data", |
| | 0 | 155 | | Render = "fields", |
| | 0 | 156 | | Collapsed = true, |
| | 0 | 157 | | Fields = complexFields.Select(f => new RenderField |
| | 0 | 158 | | { |
| | 0 | 159 | | Path = f.Name, |
| | 0 | 160 | | Label = FormatFieldName(f.Name) |
| | 0 | 161 | | }).ToList() |
| | 0 | 162 | | }); |
| | | 163 | | } |
| | | 164 | | |
| | 0 | 165 | | return new RenderDefinition |
| | 0 | 166 | | { |
| | 0 | 167 | | TitleField = titleField, |
| | 0 | 168 | | CatchAll = true, |
| | 0 | 169 | | Hidden = hidden, |
| | 0 | 170 | | Sections = sections |
| | 0 | 171 | | }; |
| | | 172 | | } |
| | | 173 | | |
| | | 174 | | // --- Heuristic helpers --- |
| | | 175 | | |
| | | 176 | | private static List<PrefixGroup> DetectPrefixGroups(List<FieldInfo> fields) |
| | | 177 | | { |
| | 0 | 178 | | var groups = new List<PrefixGroup>(); |
| | 0 | 179 | | var candidates = new Dictionary<string, List<FieldInfo>>(StringComparer.OrdinalIgnoreCase); |
| | | 180 | | |
| | 0 | 181 | | foreach (var fi in fields) |
| | | 182 | | { |
| | 0 | 183 | | var lastUnderscore = fi.Name.LastIndexOf('_'); |
| | 0 | 184 | | if (lastUnderscore <= 0) |
| | | 185 | | continue; |
| | | 186 | | |
| | | 187 | | // Try progressively shorter prefixes |
| | 0 | 188 | | var prefix = fi.Name[..lastUnderscore]; |
| | | 189 | | // Normalize: for multi-segment like "ability_score", try that first |
| | 0 | 190 | | if (!candidates.ContainsKey(prefix)) |
| | 0 | 191 | | candidates[prefix] = new List<FieldInfo>(); |
| | 0 | 192 | | candidates[prefix].Add(fi); |
| | | 193 | | } |
| | | 194 | | |
| | | 195 | | // Only keep groups with 3+ fields (meaningful grouping) |
| | | 196 | | // Also consolidate nested prefixes: prefer "ability_score" over "ability" |
| | 0 | 197 | | var validPrefixes = candidates |
| | 0 | 198 | | .Where(kv => kv.Value.Count >= 3) |
| | 0 | 199 | | .OrderByDescending(kv => kv.Key.Length) |
| | 0 | 200 | | .ToList(); |
| | | 201 | | |
| | 0 | 202 | | var claimed = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | 0 | 203 | | foreach (var kv in validPrefixes) |
| | | 204 | | { |
| | 0 | 205 | | var unclaimed = kv.Value.Where(f => !claimed.Contains(f.Name)).ToList(); |
| | 0 | 206 | | if (unclaimed.Count < 3) |
| | | 207 | | continue; |
| | | 208 | | |
| | 0 | 209 | | groups.Add(new PrefixGroup |
| | 0 | 210 | | { |
| | 0 | 211 | | Prefix = kv.Key, |
| | 0 | 212 | | Label = FormatGroupLabel(kv.Key), |
| | 0 | 213 | | Fields = unclaimed |
| | 0 | 214 | | }); |
| | 0 | 215 | | foreach (var f in unclaimed) |
| | 0 | 216 | | claimed.Add(f.Name); |
| | | 217 | | } |
| | | 218 | | |
| | 0 | 219 | | return groups; |
| | | 220 | | } |
| | | 221 | | |
| | | 222 | | private static bool IsAbilityScoreGroup(PrefixGroup group) |
| | | 223 | | { |
| | 0 | 224 | | if (group.Fields.Count != 6) |
| | 0 | 225 | | return false; |
| | 0 | 226 | | var suffixes = group.Fields |
| | 0 | 227 | | .Select(f => StripPrefix(f.Name, group.Prefix).ToLowerInvariant()) |
| | 0 | 228 | | .ToHashSet(); |
| | 0 | 229 | | return AbilitySuffixes.All(s => suffixes.Contains(s)); |
| | | 230 | | } |
| | | 231 | | |
| | | 232 | | private static bool IsNullOrEmpty(JsonElement value) |
| | | 233 | | { |
| | 0 | 234 | | return value.ValueKind switch |
| | 0 | 235 | | { |
| | 0 | 236 | | JsonValueKind.Null or JsonValueKind.Undefined => true, |
| | 0 | 237 | | JsonValueKind.String => string.IsNullOrWhiteSpace(value.GetString()) || |
| | 0 | 238 | | value.GetString() == "—" || value.GetString() == "-", |
| | 0 | 239 | | JsonValueKind.Array => value.GetArrayLength() == 0, |
| | 0 | 240 | | _ => false |
| | 0 | 241 | | }; |
| | | 242 | | } |
| | | 243 | | |
| | | 244 | | private static bool IsDescriptionField(string name) => |
| | 0 | 245 | | name.Contains("description", StringComparison.OrdinalIgnoreCase) || |
| | 0 | 246 | | name.Contains("desc", StringComparison.OrdinalIgnoreCase); |
| | | 247 | | |
| | | 248 | | private static string FormatFieldName(string name) |
| | | 249 | | { |
| | | 250 | | // "ability_score_strength" -> "Strength", "hit_points" -> "Hit Points" |
| | 0 | 251 | | return string.Join(' ', name |
| | 0 | 252 | | .Split('_') |
| | 0 | 253 | | .Where(s => !string.IsNullOrEmpty(s)) |
| | 0 | 254 | | .Select(s => char.ToUpperInvariant(s[0]) + s[1..])); |
| | | 255 | | } |
| | | 256 | | |
| | | 257 | | private static string FormatGroupLabel(string prefix) |
| | | 258 | | { |
| | | 259 | | // "saving_throw" -> "Saving Throws", "skill_bonus" -> "Skill Bonuses" |
| | 0 | 260 | | var label = FormatFieldName(prefix); |
| | | 261 | | // Simple pluralization |
| | 0 | 262 | | if (!label.EndsWith('s') && !label.EndsWith("es")) |
| | 0 | 263 | | label += "s"; |
| | 0 | 264 | | return label; |
| | | 265 | | } |
| | | 266 | | |
| | | 267 | | private static string StripPrefix(string name, string prefix) |
| | | 268 | | { |
| | 0 | 269 | | if (name.StartsWith(prefix + "_", StringComparison.OrdinalIgnoreCase)) |
| | 0 | 270 | | return name[(prefix.Length + 1)..]; |
| | 0 | 271 | | return name; |
| | | 272 | | } |
| | | 273 | | |
| | 0 | 274 | | private static RenderDefinition CreateMinimal() => new() |
| | 0 | 275 | | { |
| | 0 | 276 | | CatchAll = true, |
| | 0 | 277 | | Sections = new List<RenderSection>() |
| | 0 | 278 | | }; |
| | | 279 | | |
| | | 280 | | // --- Internal types --- |
| | | 281 | | |
| | | 282 | | private class FieldInfo |
| | | 283 | | { |
| | 0 | 284 | | public string Name { get; set; } = ""; |
| | 0 | 285 | | public JsonElement Value { get; set; } |
| | 0 | 286 | | public JsonValueKind Kind { get; set; } |
| | 0 | 287 | | public bool IsNull { get; set; } |
| | 0 | 288 | | public bool IsComplex { get; set; } |
| | | 289 | | } |
| | | 290 | | |
| | | 291 | | private class PrefixGroup |
| | | 292 | | { |
| | 0 | 293 | | public string Prefix { get; set; } = ""; |
| | 0 | 294 | | public string Label { get; set; } = ""; |
| | 0 | 295 | | public List<FieldInfo> Fields { get; set; } = new(); |
| | | 296 | | } |
| | | 297 | | } |