< Summary

Information
Class: Chronicis.Api.Services.ExternalLinks.Open5eExternalLinkProvider
Assembly: Chronicis.Api
File(s): /home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExternalLinks/Open5eExternalLinkProvider.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 508
Coverable lines: 508
Total lines: 944
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 392
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

/home/runner/work/chronicis/chronicis/src/Chronicis.Api/Services/ExternalLinks/Open5eExternalLinkProvider.cs

#LineLine coverage
 1using System.Text.Json;
 2using Chronicis.Shared.Extensions;
 3
 4namespace Chronicis.Api.Services.ExternalLinks;
 5
 6public class Open5eExternalLinkProvider : IExternalLinkProvider
 7{
 8    private const string SourceKey = "srd";
 9    private const string HttpClientName = "Open5eApi";
 10
 11    private readonly IHttpClientFactory _httpClientFactory;
 12    private readonly ILogger<Open5eExternalLinkProvider> _logger;
 13
 14    // Category definitions - all using v2 API
 15    // v2 API uses document__gamesystem__key filter for SRD content
 016    private static readonly Dictionary<string, CategoryConfig> Categories = new(StringComparer.OrdinalIgnoreCase)
 017    {
 018        ["spells"] = new("spells", "5e-2014", "Spell"),
 019        ["monsters"] = new("creatures", "5e-2014", "Monster"),
 020        ["magicitems"] = new("items", "5e-2014", "Magic Item"),
 021        ["conditions"] = new("conditions", "5e-2014", "Condition"),
 022        ["backgrounds"] = new("backgrounds", "5e-2014", "Background"),
 023        ["feats"] = new("feats", "5e-2014", "Feat"),
 024        ["classes"] = new("classes", "5e-2014", "Class"),
 025        ["races"] = new("races", "5e-2014", "Race"),
 026        ["weapons"] = new("weapons", "5e-2014", "Weapon"),
 027        ["armor"] = new("armor", "5e-2014", "Armor")
 028    };
 29
 030    public Open5eExternalLinkProvider(
 031        IHttpClientFactory httpClientFactory,
 032        ILogger<Open5eExternalLinkProvider> logger)
 33    {
 034        _httpClientFactory = httpClientFactory;
 035        _logger = logger;
 036    }
 37
 038    public string Key => SourceKey;
 39
 40    public async Task<IReadOnlyList<ExternalLinkSuggestion>> SearchAsync(string query, CancellationToken ct)
 41    {
 042        if (string.IsNullOrWhiteSpace(query))
 43        {
 44            // Empty query - return all category suggestions
 045            return GetCategorySuggestions(string.Empty);
 46        }
 47
 48        // Check if query contains a slash (indicating category/searchterm format)
 049        var slashIndex = query.IndexOf('/');
 50
 051        if (slashIndex < 0)
 52        {
 53            // No slash - user is still typing a category name
 54            // Return matching category suggestions
 055            return GetCategorySuggestions(query);
 56        }
 57
 58        // Has a slash - parse as category/searchterm
 059        var categoryPart = query[..slashIndex].Trim().ToLowerInvariant();
 060        var searchTerm = slashIndex < query.Length - 1 ? query[(slashIndex + 1)..].Trim() : string.Empty;
 61
 62        // Find the matching category (exact or prefix match)
 063        var category = Categories.Keys.FirstOrDefault(k =>
 064            k.Equals(categoryPart, StringComparison.OrdinalIgnoreCase))
 065            ?? Categories.Keys.FirstOrDefault(k =>
 066                k.StartsWith(categoryPart, StringComparison.OrdinalIgnoreCase));
 67
 068        if (category == null)
 69        {
 70            // No matching category - return filtered category suggestions
 071            return GetCategorySuggestions(categoryPart);
 72        }
 73
 074        if (string.IsNullOrWhiteSpace(searchTerm))
 75        {
 76            // Category selected but no search term yet
 77            // Return empty - user needs to type something to search
 078            return Array.Empty<ExternalLinkSuggestion>();
 79        }
 80
 81        // Search the specific category
 082        var client = _httpClientFactory.CreateClient(HttpClientName);
 83
 084        var config = Categories[category];
 085        var results = await SearchCategoryAsync(client, category, config, searchTerm, ct);
 86
 087        return results
 088            .OrderBy(s => s.Title)
 089            .Take(20)
 090            .ToList();
 091    }
 92
 93    private List<ExternalLinkSuggestion> GetCategorySuggestions(string filter)
 94    {
 095        var suggestions = new List<ExternalLinkSuggestion>();
 96
 097        foreach (var kvp in Categories)
 98        {
 099            var categoryName = kvp.Key;
 0100            var config = kvp.Value;
 101
 102            // Filter by prefix if filter is provided
 0103            if (!string.IsNullOrEmpty(filter) &&
 0104                !categoryName.StartsWith(filter, StringComparison.OrdinalIgnoreCase))
 105            {
 106                continue;
 107            }
 108
 0109            suggestions.Add(new ExternalLinkSuggestion
 0110            {
 0111                Source = Key,
 0112                Id = $"_category/{categoryName}",  // Special ID prefix for categories
 0113                Title = char.ToUpper(categoryName[0]) + categoryName[1..],  // Capitalize
 0114                Subtitle = $"Browse {config.DisplayName}s",
 0115                Category = "_category",  // Special marker
 0116                Icon = GetCategoryIcon(categoryName)
 0117            });
 118        }
 119
 0120        return suggestions.OrderBy(s => s.Title).ToList();
 121    }
 122
 123    private static string? GetCategoryIcon(string category)
 124    {
 0125        return category switch
 0126        {
 0127            "spells" => "✨",
 0128            "monsters" => "🐉",
 0129            "magicitems" => "💎",
 0130            "conditions" => "⚡",
 0131            "backgrounds" => "📜",
 0132            "feats" => "⭐",
 0133            "classes" => "⚔️",
 0134            "races" => "👤",
 0135            "weapons" => "🗡️",
 0136            "armor" => "🛡️",
 0137            _ => null
 0138        };
 139    }
 140
 141    public async Task<ExternalLinkContent> GetContentAsync(string id, CancellationToken ct)
 142    {
 0143        if (string.IsNullOrWhiteSpace(id))
 144        {
 0145            return CreateEmptyContent(id ?? string.Empty);
 146        }
 147
 148        // Parse category and key from id (e.g., "spells/srd_fireball")
 0149        var (category, itemKey) = ParseId(id);
 0150        if (string.IsNullOrEmpty(category) || string.IsNullOrEmpty(itemKey))
 151        {
 0152            _logger.LogWarningSanitized("Invalid id format: {Id}", id);
 0153            return CreateEmptyContent(id);
 154        }
 155
 0156        if (!Categories.TryGetValue(category, out var config))
 157        {
 0158            _logger.LogWarningSanitized("Unknown category in id: {Category}", category);
 0159            return CreateEmptyContent(id);
 160        }
 161
 0162        var client = _httpClientFactory.CreateClient(HttpClientName);
 163
 164        try
 165        {
 0166            var url = $"/v2/{config.Endpoint}/{itemKey}/";
 0167            _logger.LogDebugSanitized("Fetching Open5e content: {Url}", url);
 168
 0169            using var response = await client.GetAsync(url, ct);
 0170            if (!response.IsSuccessStatusCode)
 171            {
 0172                _logger.LogWarningSanitized("Open5e content fetch failed for {Id} with status {StatusCode}",
 0173                    id, response.StatusCode);
 0174                return CreateEmptyContent(id);
 175            }
 176
 0177            await using var stream = await response.Content.ReadAsStreamAsync(ct);
 0178            using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
 179
 0180            var root = document.RootElement;
 0181            var title = GetString(root, "name") ?? itemKey;
 0182            var markdown = BuildMarkdown(root, title, category, config);
 0183            var attribution = BuildAttribution(root);
 184
 0185            return new ExternalLinkContent
 0186            {
 0187                Source = Key,
 0188                Id = id,
 0189                Title = title,
 0190                Kind = config.DisplayName,
 0191                Markdown = markdown,
 0192                Attribution = attribution,
 0193                ExternalUrl = BuildWebUrl(category, itemKey)
 0194            };
 0195        }
 0196        catch (Exception ex)
 197        {
 0198            _logger.LogErrorSanitized(ex, "Open5e content fetch failed for {Id}", id);
 0199            return CreateEmptyContent(id);
 200        }
 0201    }
 202
 203    private async Task<List<ExternalLinkSuggestion>> SearchCategoryAsync(
 204        HttpClient client,
 205        string category,
 206        CategoryConfig config,
 207        string query,
 208        CancellationToken ct)
 209    {
 210        try
 211        {
 212            // Build URL with document filter (v2 API)
 0213            var docFilter = $"document__gamesystem__key={config.DocumentSlug}";
 214
 215            // Use name__contains for name-based filtering
 0216            var url = $"/v2/{config.Endpoint}/?name__contains={Uri.EscapeDataString(query)}&{docFilter}&limit=50";
 217
 0218            using var response = await client.GetAsync(url, ct);
 0219            if (!response.IsSuccessStatusCode)
 220            {
 0221                _logger.LogWarningSanitized("Open5e search failed for {Category} with status {StatusCode}",
 0222                    category, response.StatusCode);
 0223                return new List<ExternalLinkSuggestion>();
 224            }
 225
 0226            await using var stream = await response.Content.ReadAsStreamAsync(ct);
 0227            using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
 228
 0229            var results = document.RootElement.GetProperty("results");
 0230            var suggestions = new List<ExternalLinkSuggestion>();
 231
 0232            foreach (var item in results.EnumerateArray())
 233            {
 234                // Filter to only include items where the name contains the search term
 235                // (Open5e searches full content, we want name-only matching)
 0236                var name = GetString(item, "name");
 0237                if (string.IsNullOrEmpty(name) ||
 0238                    !name.Contains(query, StringComparison.OrdinalIgnoreCase))
 239                {
 240                    continue;
 241                }
 242
 0243                var suggestion = ParseSearchResult(item, category, config);
 0244                if (suggestion != null)
 245                {
 0246                    suggestions.Add(suggestion);
 247                }
 248            }
 249
 0250            return suggestions;
 0251        }
 0252        catch (Exception ex)
 253        {
 0254            _logger.LogErrorSanitized(ex, "Open5e search failed for category {Category} query {Query}", category, query)
 0255            return new List<ExternalLinkSuggestion>();
 256        }
 0257    }
 258
 259    private ExternalLinkSuggestion? ParseSearchResult(JsonElement item, string category, CategoryConfig config)
 260    {
 261        // v2 API uses "key" for item identifier
 0262        var itemKey = GetString(item, "key");
 0263        var name = GetString(item, "name");
 264
 0265        if (string.IsNullOrEmpty(itemKey) || string.IsNullOrEmpty(name))
 266        {
 0267            return null;
 268        }
 269
 270        // Build subtitle with additional context
 0271        var subtitle = BuildSubtitle(item, category, config);
 272
 0273        return new ExternalLinkSuggestion
 0274        {
 0275            Source = Key,
 0276            Id = $"{category}/{itemKey}",
 0277            Title = name,
 0278            Subtitle = subtitle,
 0279            Category = category,
 0280            Href = BuildWebUrl(category, itemKey)
 0281        };
 282    }
 283
 284    private string BuildSubtitle(JsonElement item, string category, CategoryConfig config)
 285    {
 0286        var parts = new List<string> { config.DisplayName };
 287
 288        switch (category)
 289        {
 290            case "spells":
 0291                var level = GetInt(item, "level");
 0292                var school = GetStringFromObject(item, "school", "name");
 0293                if (level.HasValue)
 294                {
 0295                    parts.Add(level == 0 ? "Cantrip" : $"Level {level}");
 296                }
 0297                if (!string.IsNullOrEmpty(school))
 298                {
 0299                    parts.Add(school);
 300                }
 0301                break;
 302
 303            case "monsters":
 0304                var cr = GetString(item, "challenge_rating");
 0305                var type = GetString(item, "type");
 0306                if (!string.IsNullOrEmpty(cr))
 307                {
 0308                    parts.Add($"CR {cr}");
 309                }
 0310                if (!string.IsNullOrEmpty(type))
 311                {
 0312                    parts.Add(type);
 313                }
 0314                break;
 315
 316            case "magicitems":
 0317                var rarity = GetString(item, "rarity");
 0318                var itemType = GetString(item, "type");
 0319                if (!string.IsNullOrEmpty(rarity))
 320                {
 0321                    parts.Add(rarity);
 322                }
 0323                if (!string.IsNullOrEmpty(itemType))
 324                {
 0325                    parts.Add(itemType);
 326                }
 0327                break;
 328
 329            case "armor":
 330            case "weapons":
 0331                var category_range = GetString(item, "category_range") ?? GetString(item, "category");
 0332                if (!string.IsNullOrEmpty(category_range))
 333                {
 0334                    parts.Add(category_range);
 335                }
 336                break;
 337        }
 338
 0339        return string.Join(" • ", parts);
 340    }
 341
 342    private string? BuildWebUrl(string category, string itemKey)
 343    {
 344        // Map to Open5e website URLs
 0345        var webCategory = category switch
 0346        {
 0347            "magicitems" => "magic-items",
 0348            _ => category
 0349        };
 350
 0351        return $"https://open5e.com/{webCategory}/{itemKey}";
 352    }
 353
 354    private static (string? category, string searchTerm) ParseQuery(string query)
 355    {
 0356        var trimmed = query.Trim();
 0357        var slashIndex = trimmed.IndexOf('/');
 358
 0359        if (slashIndex > 0 && slashIndex < trimmed.Length - 1)
 360        {
 0361            var possibleCategory = trimmed[..slashIndex].ToLowerInvariant();
 362
 363            // Try exact match first
 0364            if (Categories.ContainsKey(possibleCategory))
 365            {
 0366                return (possibleCategory, trimmed[(slashIndex + 1)..].Trim());
 367            }
 368
 369            // Try prefix/partial match (e.g., "spell" matches "spells", "monster" matches "monsters")
 0370            var matchedCategory = Categories.Keys
 0371                .FirstOrDefault(k => k.StartsWith(possibleCategory, StringComparison.OrdinalIgnoreCase));
 372
 0373            if (matchedCategory != null)
 374            {
 0375                return (matchedCategory, trimmed[(slashIndex + 1)..].Trim());
 376            }
 377        }
 378
 0379        return (null, trimmed);
 380    }
 381
 382    private static (string? category, string? itemKey) ParseId(string id)
 383    {
 0384        var slashIndex = id.IndexOf('/');
 0385        if (slashIndex > 0 && slashIndex < id.Length - 1)
 386        {
 0387            return (id[..slashIndex], id[(slashIndex + 1)..]);
 388        }
 389
 0390        return (null, null);
 391    }
 392
 393    private string BuildMarkdown(JsonElement root, string title, string category, CategoryConfig config)
 394    {
 0395        return category switch
 0396        {
 0397            "spells" => BuildSpellMarkdown(root, title),
 0398            "monsters" => BuildMonsterMarkdown(root, title),
 0399            "magicitems" => BuildMagicItemMarkdown(root, title),
 0400            "conditions" => BuildSimpleMarkdown(root, title, "desc"),
 0401            "backgrounds" => BuildBackgroundMarkdown(root, title),
 0402            "feats" => BuildFeatMarkdown(root, title),
 0403            "classes" => BuildClassMarkdown(root, title),
 0404            "races" => BuildRaceMarkdown(root, title),
 0405            "weapons" => BuildWeaponMarkdown(root, title),
 0406            "armor" => BuildArmorMarkdown(root, title),
 0407            _ => BuildSimpleMarkdown(root, title, "desc")
 0408        };
 409    }
 410
 411    private string BuildSpellMarkdown(JsonElement root, string title)
 412    {
 0413        var sb = new System.Text.StringBuilder();
 0414        sb.AppendLine($"# {title}");
 0415        sb.AppendLine();
 416
 417        // Spell details
 0418        var level = GetInt(root, "level");
 0419        var school = GetStringFromObject(root, "school", "name");
 0420        var levelText = level == 0 ? "Cantrip" : $"Level {level}";
 0421        if (!string.IsNullOrEmpty(school))
 422        {
 0423            sb.AppendLine($"*{levelText} {school}*");
 424        }
 425        else
 426        {
 0427            sb.AppendLine($"*{levelText}*");
 428        }
 0429        sb.AppendLine();
 430
 431        // Casting info
 0432        var castingTime = GetString(root, "casting_time");
 0433        var range = GetString(root, "range_text") ?? GetString(root, "range")?.ToString();
 0434        var duration = GetString(root, "duration");
 0435        var concentration = GetBool(root, "concentration");
 0436        var ritual = GetBool(root, "ritual");
 437
 0438        sb.AppendLine("## Casting");
 0439        if (!string.IsNullOrEmpty(castingTime))
 0440            sb.AppendLine($"- **Casting Time:** {castingTime}{(ritual == true ? " (ritual)" : "")}");
 0441        if (!string.IsNullOrEmpty(range))
 0442            sb.AppendLine($"- **Range:** {range}");
 0443        if (!string.IsNullOrEmpty(duration))
 0444            sb.AppendLine($"- **Duration:** {(concentration == true ? "Concentration, " : "")}{duration}");
 445
 446        // Components
 0447        var verbal = GetBool(root, "verbal");
 0448        var somatic = GetBool(root, "somatic");
 0449        var material = GetBool(root, "material");
 0450        var materialDesc = GetString(root, "material_specified") ?? GetString(root, "material");
 451
 0452        var components = new List<string>();
 0453        if (verbal == true)
 0454            components.Add("V");
 0455        if (somatic == true)
 0456            components.Add("S");
 0457        if (material == true)
 0458            components.Add($"M{(!string.IsNullOrEmpty(materialDesc) ? $" ({materialDesc})" : "")}");
 0459        if (components.Count > 0)
 0460            sb.AppendLine($"- **Components:** {string.Join(", ", components)}");
 461
 0462        sb.AppendLine();
 463
 464        // Description
 0465        var desc = GetString(root, "desc");
 0466        if (!string.IsNullOrEmpty(desc))
 467        {
 0468            sb.AppendLine("## Description");
 0469            sb.AppendLine(desc);
 0470            sb.AppendLine();
 471        }
 472
 473        // Higher levels
 0474        var higherLevel = GetString(root, "higher_level");
 0475        if (!string.IsNullOrEmpty(higherLevel))
 476        {
 0477            sb.AppendLine("## At Higher Levels");
 0478            sb.AppendLine(higherLevel);
 479        }
 480
 0481        return sb.ToString().Trim();
 482    }
 483
 484    private string BuildMonsterMarkdown(JsonElement root, string title)
 485    {
 0486        var sb = new System.Text.StringBuilder();
 0487        sb.AppendLine($"# {title}");
 0488        sb.AppendLine();
 489
 490        // Type line - v2 API returns size and type as objects with "name" property
 0491        var size = GetStringFromObject(root, "size", "name") ?? GetString(root, "size");
 0492        var type = GetStringFromObject(root, "type", "name") ?? GetString(root, "type");
 0493        var alignment = GetString(root, "alignment");
 0494        var typeLine = string.Join(" ", new[] { size, type }.Where(s => !string.IsNullOrEmpty(s)));
 0495        if (!string.IsNullOrEmpty(alignment))
 0496            typeLine += $", {alignment}";
 0497        if (!string.IsNullOrEmpty(typeLine))
 498        {
 0499            sb.AppendLine($"*{typeLine}*");
 0500            sb.AppendLine();
 501        }
 502
 503        // Basic stats
 0504        sb.AppendLine("## Statistics");
 0505        var ac = GetString(root, "armor_class") ?? GetInt(root, "armor_class")?.ToString();
 0506        var hp = GetString(root, "hit_points") ?? GetInt(root, "hit_points")?.ToString();
 0507        var hitDice = GetString(root, "hit_dice");
 0508        var cr = GetString(root, "challenge_rating") ?? GetString(root, "cr");
 509
 0510        if (!string.IsNullOrEmpty(ac))
 0511            sb.AppendLine($"**Armor Class:** {ac}");
 0512        if (!string.IsNullOrEmpty(hp))
 0513            sb.AppendLine($"**Hit Points:** {hp}{(!string.IsNullOrEmpty(hitDice) ? $" ({hitDice})" : "")}");
 0514        if (!string.IsNullOrEmpty(cr))
 0515            sb.AppendLine($"**Challenge:** {cr}");
 516
 517        // Speed
 0518        var speed = GetSpeedString(root);
 0519        if (!string.IsNullOrEmpty(speed))
 0520            sb.AppendLine($"**Speed:** {speed}");
 521
 0522        sb.AppendLine();
 523
 524        // Actions
 0525        AppendNamedArray(sb, root, "actions", "Actions");
 526
 527        // Special abilities
 0528        AppendNamedArray(sb, root, "special_abilities", "Special Abilities");
 529
 530        // Legendary actions
 0531        AppendNamedArray(sb, root, "legendary_actions", "Legendary Actions");
 532
 0533        return sb.ToString().Trim();
 534    }
 535
 536    private string BuildMagicItemMarkdown(JsonElement root, string title)
 537    {
 0538        var sb = new System.Text.StringBuilder();
 0539        sb.AppendLine($"# {title}");
 0540        sb.AppendLine();
 541
 0542        var type = GetString(root, "type");
 0543        var rarity = GetString(root, "rarity");
 0544        var attunement = GetString(root, "requires_attunement");
 545
 0546        var subtitle = string.Join(", ", new[] { type, rarity }.Where(s => !string.IsNullOrEmpty(s)));
 0547        if (!string.IsNullOrEmpty(attunement) && attunement.ToLower() != "false")
 0548            subtitle += $" ({attunement})";
 549
 0550        if (!string.IsNullOrEmpty(subtitle))
 551        {
 0552            sb.AppendLine($"*{subtitle}*");
 0553            sb.AppendLine();
 554        }
 555
 0556        var desc = GetString(root, "desc");
 0557        if (!string.IsNullOrEmpty(desc))
 558        {
 0559            sb.AppendLine(desc);
 560        }
 561
 0562        return sb.ToString().Trim();
 563    }
 564
 565    private string BuildBackgroundMarkdown(JsonElement root, string title)
 566    {
 0567        var sb = new System.Text.StringBuilder();
 0568        sb.AppendLine($"# {title}");
 0569        sb.AppendLine();
 570
 0571        var desc = GetString(root, "desc");
 0572        if (!string.IsNullOrEmpty(desc))
 573        {
 0574            sb.AppendLine(desc);
 0575            sb.AppendLine();
 576        }
 577
 578        // Skill proficiencies
 0579        var skillProf = GetString(root, "skill_proficiencies");
 0580        if (!string.IsNullOrEmpty(skillProf))
 581        {
 0582            sb.AppendLine($"**Skill Proficiencies:** {skillProf}");
 0583            sb.AppendLine();
 584        }
 585
 586        // Equipment
 0587        var equipment = GetString(root, "equipment");
 0588        if (!string.IsNullOrEmpty(equipment))
 589        {
 0590            sb.AppendLine($"**Equipment:** {equipment}");
 0591            sb.AppendLine();
 592        }
 593
 594        // Feature
 0595        var featureName = GetString(root, "feature");
 0596        var featureDesc = GetString(root, "feature_desc");
 0597        if (!string.IsNullOrEmpty(featureName))
 598        {
 0599            sb.AppendLine($"## {featureName}");
 0600            if (!string.IsNullOrEmpty(featureDesc))
 0601                sb.AppendLine(featureDesc);
 602        }
 603
 0604        return sb.ToString().Trim();
 605    }
 606
 607    private string BuildFeatMarkdown(JsonElement root, string title)
 608    {
 0609        var sb = new System.Text.StringBuilder();
 0610        sb.AppendLine($"# {title}");
 0611        sb.AppendLine();
 612
 0613        var prerequisite = GetString(root, "prerequisite");
 0614        if (!string.IsNullOrEmpty(prerequisite))
 615        {
 0616            sb.AppendLine($"*Prerequisite: {prerequisite}*");
 0617            sb.AppendLine();
 618        }
 619
 0620        var desc = GetString(root, "desc");
 0621        if (!string.IsNullOrEmpty(desc))
 622        {
 0623            sb.AppendLine(desc);
 624        }
 625
 0626        return sb.ToString().Trim();
 627    }
 628
 629    private string BuildClassMarkdown(JsonElement root, string title)
 630    {
 0631        var sb = new System.Text.StringBuilder();
 0632        sb.AppendLine($"# {title}");
 0633        sb.AppendLine();
 634
 0635        var hitDie = GetString(root, "hit_dice");
 0636        if (!string.IsNullOrEmpty(hitDie))
 637        {
 0638            sb.AppendLine($"**Hit Die:** {hitDie}");
 0639            sb.AppendLine();
 640        }
 641
 0642        var desc = GetString(root, "desc");
 0643        if (!string.IsNullOrEmpty(desc))
 644        {
 0645            sb.AppendLine(desc);
 646        }
 647
 0648        return sb.ToString().Trim();
 649    }
 650
 651    private string BuildRaceMarkdown(JsonElement root, string title)
 652    {
 0653        var sb = new System.Text.StringBuilder();
 0654        sb.AppendLine($"# {title}");
 0655        sb.AppendLine();
 656
 0657        var size = GetString(root, "size");
 0658        var speed = GetString(root, "speed") ?? GetInt(root, "speed_desc")?.ToString();
 659
 0660        if (!string.IsNullOrEmpty(size))
 0661            sb.AppendLine($"**Size:** {size}");
 0662        if (!string.IsNullOrEmpty(speed))
 0663            sb.AppendLine($"**Speed:** {speed}");
 664
 0665        sb.AppendLine();
 666
 0667        var desc = GetString(root, "desc");
 0668        if (!string.IsNullOrEmpty(desc))
 669        {
 0670            sb.AppendLine(desc);
 0671            sb.AppendLine();
 672        }
 673
 674        // Traits
 0675        var traits = GetString(root, "traits");
 0676        if (!string.IsNullOrEmpty(traits))
 677        {
 0678            sb.AppendLine("## Traits");
 0679            sb.AppendLine(traits);
 680        }
 681
 0682        return sb.ToString().Trim();
 683    }
 684
 685    private string BuildWeaponMarkdown(JsonElement root, string title)
 686    {
 0687        var sb = new System.Text.StringBuilder();
 0688        sb.AppendLine($"# {title}");
 0689        sb.AppendLine();
 690
 0691        var category = GetString(root, "category") ?? GetString(root, "category_range");
 0692        if (!string.IsNullOrEmpty(category))
 693        {
 0694            sb.AppendLine($"*{category}*");
 0695            sb.AppendLine();
 696        }
 697
 0698        var damage = GetString(root, "damage_dice") ?? GetString(root, "damage");
 0699        var damageType = GetString(root, "damage_type");
 0700        var cost = GetString(root, "cost");
 0701        var weight = GetString(root, "weight");
 702
 0703        if (!string.IsNullOrEmpty(damage))
 0704            sb.AppendLine($"**Damage:** {damage}{(!string.IsNullOrEmpty(damageType) ? $" {damageType}" : "")}");
 0705        if (!string.IsNullOrEmpty(cost))
 0706            sb.AppendLine($"**Cost:** {cost}");
 0707        if (!string.IsNullOrEmpty(weight))
 0708            sb.AppendLine($"**Weight:** {weight}");
 709
 0710        var properties = GetStringArray(root, "properties");
 0711        if (properties.Count > 0)
 0712            sb.AppendLine($"**Properties:** {string.Join(", ", properties)}");
 713
 0714        return sb.ToString().Trim();
 715    }
 716
 717    private string BuildArmorMarkdown(JsonElement root, string title)
 718    {
 0719        var sb = new System.Text.StringBuilder();
 0720        sb.AppendLine($"# {title}");
 0721        sb.AppendLine();
 722
 0723        var category = GetString(root, "category");
 0724        if (!string.IsNullOrEmpty(category))
 725        {
 0726            sb.AppendLine($"*{category}*");
 0727            sb.AppendLine();
 728        }
 729
 0730        var ac = GetString(root, "base_ac") ?? GetString(root, "ac_string");
 0731        var cost = GetString(root, "cost");
 0732        var weight = GetString(root, "weight");
 0733        var strength = GetString(root, "strength_requirement");
 0734        var stealth = GetString(root, "stealth_disadvantage");
 735
 0736        if (!string.IsNullOrEmpty(ac))
 0737            sb.AppendLine($"**Armor Class:** {ac}");
 0738        if (!string.IsNullOrEmpty(cost))
 0739            sb.AppendLine($"**Cost:** {cost}");
 0740        if (!string.IsNullOrEmpty(weight))
 0741            sb.AppendLine($"**Weight:** {weight}");
 0742        if (!string.IsNullOrEmpty(strength))
 0743            sb.AppendLine($"**Strength Required:** {strength}");
 0744        if (stealth == "true" || stealth == "True")
 0745            sb.AppendLine($"**Stealth:** Disadvantage");
 746
 0747        return sb.ToString().Trim();
 748    }
 749
 750    private string BuildSimpleMarkdown(JsonElement root, string title, string descField)
 751    {
 0752        var sb = new System.Text.StringBuilder();
 0753        sb.AppendLine($"# {title}");
 0754        sb.AppendLine();
 755
 0756        var desc = GetString(root, descField);
 0757        if (!string.IsNullOrEmpty(desc))
 758        {
 0759            sb.AppendLine(desc);
 760        }
 761
 0762        return sb.ToString().Trim();
 763    }
 764
 765    private string BuildAttribution(JsonElement root)
 766    {
 767        // Try to get document info
 0768        var docTitle = GetStringFromObject(root, "document", "name")
 0769            ?? GetString(root, "document__title")
 0770            ?? "System Reference Document 5.1";
 771
 0772        return $"Source: {docTitle}";
 773    }
 774
 775    private void AppendAbilityScores(System.Text.StringBuilder sb, JsonElement root)
 776    {
 0777        var str = GetInt(root, "strength");
 0778        var dex = GetInt(root, "dexterity");
 0779        var con = GetInt(root, "constitution");
 0780        var intel = GetInt(root, "intelligence");
 0781        var wis = GetInt(root, "wisdom");
 0782        var cha = GetInt(root, "charisma");
 783
 0784        if (str.HasValue || dex.HasValue)
 785        {
 0786            sb.AppendLine("## Ability Scores");
 0787            sb.AppendLine($"| STR | DEX | CON | INT | WIS | CHA |");
 0788            sb.AppendLine($"|:---:|:---:|:---:|:---:|:---:|:---:|");
 0789            sb.AppendLine($"| {str ?? 10} | {dex ?? 10} | {con ?? 10} | {intel ?? 10} | {wis ?? 10} | {cha ?? 10} |");
 0790            sb.AppendLine();
 791        }
 0792    }
 793
 794    private void AppendNamedArray(System.Text.StringBuilder sb, JsonElement root, string field, string header)
 795    {
 0796        if (!root.TryGetProperty(field, out var array) || array.ValueKind != JsonValueKind.Array)
 0797            return;
 798
 0799        var items = array.EnumerateArray().ToList();
 0800        if (items.Count == 0)
 0801            return;
 802
 0803        sb.AppendLine($"## {header}");
 0804        foreach (var item in items)
 805        {
 0806            var name = GetString(item, "name");
 0807            var desc = GetString(item, "desc");
 808
 0809            if (!string.IsNullOrEmpty(name))
 810            {
 0811                sb.AppendLine($"### {name}");
 812            }
 0813            if (!string.IsNullOrEmpty(desc))
 814            {
 0815                sb.AppendLine(desc);
 816            }
 0817            sb.AppendLine();
 818        }
 0819    }
 820
 821    private string? GetSpeedString(JsonElement root)
 822    {
 0823        if (root.TryGetProperty("speed", out var speed))
 824        {
 0825            if (speed.ValueKind == JsonValueKind.Object)
 826            {
 0827                var parts = new List<string>();
 0828                foreach (var prop in speed.EnumerateObject())
 829                {
 0830                    var value = prop.Value.ValueKind == JsonValueKind.String
 0831                        ? prop.Value.GetString()
 0832                        : prop.Value.ToString();
 0833                    parts.Add($"{prop.Name} {value}");
 834                }
 0835                return string.Join(", ", parts);
 836            }
 0837            return speed.ToString();
 838        }
 0839        return null;
 840    }
 841
 842    private ExternalLinkContent CreateEmptyContent(string id)
 843    {
 0844        return new ExternalLinkContent
 0845        {
 0846            Source = Key,
 0847            Id = id,
 0848            Title = string.Empty,
 0849            Kind = string.Empty,
 0850            Markdown = string.Empty
 0851        };
 852    }
 853
 854    private static string? GetString(JsonElement element, string propertyName)
 855    {
 0856        if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var value))
 857        {
 0858            return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString();
 859        }
 0860        return null;
 861    }
 862
 863    private static int? GetInt(JsonElement element, string propertyName)
 864    {
 0865        if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var value))
 866        {
 0867            if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number))
 868            {
 0869                return number;
 870            }
 871        }
 0872        return null;
 873    }
 874
 875    private static string? GetStringFromObject(JsonElement element, string propertyName, string childPropertyName)
 876    {
 0877        if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var value))
 878        {
 0879            return GetString(value, childPropertyName);
 880        }
 0881        return null;
 882    }
 883
 884    private static bool? GetBool(JsonElement element, string propertyName)
 885    {
 0886        if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var value))
 887        {
 0888            if (value.ValueKind == JsonValueKind.True)
 0889                return true;
 0890            if (value.ValueKind == JsonValueKind.False)
 0891                return false;
 0892            if (value.ValueKind == JsonValueKind.String)
 893            {
 0894                var str = value.GetString()?.ToLower();
 0895                return str == "true" || str == "yes";
 896            }
 897        }
 0898        return null;
 899    }
 900
 901    private static List<string> GetStringArray(JsonElement element, string propertyName)
 902    {
 0903        var list = new List<string>();
 0904        if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var value))
 0905            return list;
 906
 0907        if (value.ValueKind == JsonValueKind.Array)
 908        {
 0909            foreach (var item in value.EnumerateArray())
 910            {
 0911                string? str = null;
 0912                if (item.ValueKind == JsonValueKind.String)
 913                {
 0914                    str = item.GetString();
 915                }
 0916                else if (item.ValueKind == JsonValueKind.Object)
 917                {
 918                    // Some arrays contain objects with a "name" property
 0919                    str = GetString(item, "name");
 920                }
 921
 0922                if (!string.IsNullOrWhiteSpace(str))
 923                {
 0924                    list.Add(str);
 925                }
 926            }
 927        }
 0928        else if (value.ValueKind == JsonValueKind.String)
 929        {
 0930            var str = value.GetString();
 0931            if (!string.IsNullOrWhiteSpace(str))
 932            {
 0933                list.Add(str);
 934            }
 935        }
 936
 0937        return list;
 938    }
 939
 0940    private sealed record CategoryConfig(
 0941        string Endpoint,
 0942        string DocumentSlug,
 0943        string DisplayName);
 944}

Methods/Properties

.cctor()
.ctor(System.Net.Http.IHttpClientFactory,Microsoft.Extensions.Logging.ILogger`1<Chronicis.Api.Services.ExternalLinks.Open5eExternalLinkProvider>)
get_Key()
SearchAsync()
GetCategorySuggestions(System.String)
GetCategoryIcon(System.String)
GetContentAsync()
SearchCategoryAsync()
ParseSearchResult(System.Text.Json.JsonElement,System.String,Chronicis.Api.Services.ExternalLinks.Open5eExternalLinkProvider/CategoryConfig)
BuildSubtitle(System.Text.Json.JsonElement,System.String,Chronicis.Api.Services.ExternalLinks.Open5eExternalLinkProvider/CategoryConfig)
BuildWebUrl(System.String,System.String)
ParseQuery(System.String)
ParseId(System.String)
BuildMarkdown(System.Text.Json.JsonElement,System.String,System.String,Chronicis.Api.Services.ExternalLinks.Open5eExternalLinkProvider/CategoryConfig)
BuildSpellMarkdown(System.Text.Json.JsonElement,System.String)
BuildMonsterMarkdown(System.Text.Json.JsonElement,System.String)
BuildMagicItemMarkdown(System.Text.Json.JsonElement,System.String)
BuildBackgroundMarkdown(System.Text.Json.JsonElement,System.String)
BuildFeatMarkdown(System.Text.Json.JsonElement,System.String)
BuildClassMarkdown(System.Text.Json.JsonElement,System.String)
BuildRaceMarkdown(System.Text.Json.JsonElement,System.String)
BuildWeaponMarkdown(System.Text.Json.JsonElement,System.String)
BuildArmorMarkdown(System.Text.Json.JsonElement,System.String)
BuildSimpleMarkdown(System.Text.Json.JsonElement,System.String,System.String)
BuildAttribution(System.Text.Json.JsonElement)
AppendAbilityScores(System.Text.StringBuilder,System.Text.Json.JsonElement)
AppendNamedArray(System.Text.StringBuilder,System.Text.Json.JsonElement,System.String,System.String)
GetSpeedString(System.Text.Json.JsonElement)
CreateEmptyContent(System.String)
GetString(System.Text.Json.JsonElement,System.String)
GetInt(System.Text.Json.JsonElement,System.String)
GetStringFromObject(System.Text.Json.JsonElement,System.String,System.String)
GetBool(System.Text.Json.JsonElement,System.String)
GetStringArray(System.Text.Json.JsonElement,System.String)
.ctor(System.String,System.String,System.String)
get_Endpoint()
get_DocumentSlug()
get_DisplayName()