| | | 1 | | using System.Collections.Frozen; |
| | | 2 | | using System.Text.Json; |
| | | 3 | | using static Chronicis.Api.Services.ExternalLinks.Open5eJsonHelpers; |
| | | 4 | | |
| | | 5 | | namespace Chronicis.Api.Services.ExternalLinks; |
| | | 6 | | |
| | | 7 | | public sealed class Open5eExternalLinkProvider : IExternalLinkProvider |
| | | 8 | | { |
| | | 9 | | private const string SourceKey = "srd"; |
| | | 10 | | private const string HttpClientName = "Open5eApi"; |
| | | 11 | | |
| | | 12 | | private readonly IHttpClientFactory _httpClientFactory; |
| | | 13 | | private readonly ILogger<Open5eExternalLinkProvider> _logger; |
| | | 14 | | |
| | 1 | 15 | | private static readonly FrozenDictionary<string, IOpen5eCategoryStrategy> Strategies = |
| | 1 | 16 | | CreateStrategies(); |
| | | 17 | | |
| | | 18 | | public Open5eExternalLinkProvider( |
| | | 19 | | IHttpClientFactory httpClientFactory, |
| | | 20 | | ILogger<Open5eExternalLinkProvider> logger) |
| | | 21 | | { |
| | 31 | 22 | | _httpClientFactory = httpClientFactory; |
| | 31 | 23 | | _logger = logger; |
| | 31 | 24 | | } |
| | | 25 | | |
| | 64 | 26 | | public string Key => SourceKey; |
| | | 27 | | |
| | | 28 | | public async Task<IReadOnlyList<ExternalLinkSuggestion>> SearchAsync(string query, CancellationToken ct) |
| | | 29 | | { |
| | | 30 | | if (string.IsNullOrWhiteSpace(query)) |
| | | 31 | | return GetCategorySuggestions(string.Empty); |
| | | 32 | | |
| | | 33 | | var slashIndex = query.IndexOf('/'); |
| | | 34 | | |
| | | 35 | | if (slashIndex < 0) |
| | | 36 | | return GetCategorySuggestions(query); |
| | | 37 | | |
| | | 38 | | var categoryPart = query[..slashIndex].Trim().ToLowerInvariant(); |
| | | 39 | | var searchTerm = slashIndex < query.Length - 1 ? query[(slashIndex + 1)..].Trim() : string.Empty; |
| | | 40 | | |
| | | 41 | | var strategy = FindStrategy(categoryPart); |
| | | 42 | | if (strategy == null) |
| | | 43 | | return GetCategorySuggestions(categoryPart); |
| | | 44 | | |
| | | 45 | | if (string.IsNullOrWhiteSpace(searchTerm)) |
| | | 46 | | return Array.Empty<ExternalLinkSuggestion>(); |
| | | 47 | | |
| | | 48 | | var client = _httpClientFactory.CreateClient(HttpClientName); |
| | | 49 | | var results = await SearchCategoryAsync(client, strategy, searchTerm, ct); |
| | | 50 | | |
| | | 51 | | return results |
| | | 52 | | .OrderBy(s => s.Title) |
| | | 53 | | .Take(20) |
| | | 54 | | .ToList(); |
| | | 55 | | } |
| | | 56 | | |
| | | 57 | | public async Task<ExternalLinkContent> GetContentAsync(string id, CancellationToken ct) |
| | | 58 | | { |
| | | 59 | | if (string.IsNullOrWhiteSpace(id)) |
| | | 60 | | return CreateEmptyContent(id ?? string.Empty); |
| | | 61 | | |
| | | 62 | | var (category, itemKey) = ParseId(id); |
| | | 63 | | if (string.IsNullOrEmpty(category) || string.IsNullOrEmpty(itemKey)) |
| | | 64 | | { |
| | | 65 | | _logger.LogWarningSanitized("Invalid id format: {Id}", id); |
| | | 66 | | return CreateEmptyContent(id); |
| | | 67 | | } |
| | | 68 | | |
| | | 69 | | if (!Strategies.TryGetValue(category, out var strategy)) |
| | | 70 | | { |
| | | 71 | | _logger.LogWarningSanitized("Unknown category in id: {Category}", category); |
| | | 72 | | return CreateEmptyContent(id); |
| | | 73 | | } |
| | | 74 | | |
| | | 75 | | var client = _httpClientFactory.CreateClient(HttpClientName); |
| | | 76 | | |
| | | 77 | | try |
| | | 78 | | { |
| | | 79 | | var url = $"/v2/{strategy.Endpoint}/{itemKey}/"; |
| | | 80 | | _logger.LogTraceSanitized("Fetching Open5e content: {Url}", url); |
| | | 81 | | |
| | | 82 | | using var response = await client.GetAsync(url, ct); |
| | | 83 | | if (!response.IsSuccessStatusCode) |
| | | 84 | | { |
| | | 85 | | _logger.LogWarningSanitized("Open5e content fetch failed for {Id} with status {StatusCode}", |
| | | 86 | | id, response.StatusCode); |
| | | 87 | | return CreateEmptyContent(id); |
| | | 88 | | } |
| | | 89 | | |
| | | 90 | | await using var stream = await response.Content.ReadAsStreamAsync(ct); |
| | | 91 | | using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct); |
| | | 92 | | |
| | | 93 | | var root = document.RootElement; |
| | | 94 | | var title = GetString(root, "name") ?? itemKey; |
| | | 95 | | var markdown = strategy.BuildMarkdown(root, title); |
| | | 96 | | var attribution = BuildAttribution(root); |
| | | 97 | | |
| | | 98 | | return new ExternalLinkContent |
| | | 99 | | { |
| | | 100 | | Source = Key, |
| | | 101 | | Id = id, |
| | | 102 | | Title = title, |
| | | 103 | | Kind = strategy.DisplayName, |
| | | 104 | | Markdown = markdown, |
| | | 105 | | Attribution = attribution, |
| | | 106 | | ExternalUrl = BuildWebUrl(strategy, itemKey) |
| | | 107 | | }; |
| | | 108 | | } |
| | | 109 | | catch (Exception ex) |
| | | 110 | | { |
| | | 111 | | _logger.LogErrorSanitized(ex, "Open5e content fetch failed for {Id}", id); |
| | | 112 | | return CreateEmptyContent(id); |
| | | 113 | | } |
| | | 114 | | } |
| | | 115 | | |
| | | 116 | | private List<ExternalLinkSuggestion> GetCategorySuggestions(string filter) |
| | | 117 | | { |
| | 3 | 118 | | var suggestions = new List<ExternalLinkSuggestion>(); |
| | | 119 | | |
| | 66 | 120 | | foreach (var strategy in Strategies.Values) |
| | | 121 | | { |
| | 30 | 122 | | if (!string.IsNullOrEmpty(filter) && |
| | 30 | 123 | | !strategy.CategoryKey.StartsWith(filter, StringComparison.OrdinalIgnoreCase)) |
| | | 124 | | { |
| | | 125 | | continue; |
| | | 126 | | } |
| | | 127 | | |
| | 11 | 128 | | suggestions.Add(new ExternalLinkSuggestion |
| | 11 | 129 | | { |
| | 11 | 130 | | Source = Key, |
| | 11 | 131 | | Id = $"_category/{strategy.CategoryKey}", |
| | 11 | 132 | | Title = char.ToUpper(strategy.CategoryKey[0]) + strategy.CategoryKey[1..], |
| | 11 | 133 | | Subtitle = $"Browse {strategy.DisplayName}s", |
| | 11 | 134 | | Category = "_category", |
| | 11 | 135 | | Icon = strategy.Icon |
| | 11 | 136 | | }); |
| | | 137 | | } |
| | | 138 | | |
| | 3 | 139 | | return suggestions.OrderBy(s => s.Title).ToList(); |
| | | 140 | | } |
| | | 141 | | |
| | | 142 | | private async Task<List<ExternalLinkSuggestion>> SearchCategoryAsync( |
| | | 143 | | HttpClient client, |
| | | 144 | | IOpen5eCategoryStrategy strategy, |
| | | 145 | | string query, |
| | | 146 | | CancellationToken ct) |
| | | 147 | | { |
| | | 148 | | try |
| | | 149 | | { |
| | | 150 | | var docFilter = $"document__gamesystem__key={strategy.DocumentSlug}"; |
| | | 151 | | var url = $"/v2/{strategy.Endpoint}/?name__contains={Uri.EscapeDataString(query)}&{docFilter}&limit=50"; |
| | | 152 | | |
| | | 153 | | using var response = await client.GetAsync(url, ct); |
| | | 154 | | if (!response.IsSuccessStatusCode) |
| | | 155 | | { |
| | | 156 | | _logger.LogWarningSanitized("Open5e search failed for {Category} with status {StatusCode}", |
| | | 157 | | strategy.CategoryKey, response.StatusCode); |
| | | 158 | | return new List<ExternalLinkSuggestion>(); |
| | | 159 | | } |
| | | 160 | | |
| | | 161 | | await using var stream = await response.Content.ReadAsStreamAsync(ct); |
| | | 162 | | using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct); |
| | | 163 | | |
| | | 164 | | var results = document.RootElement.GetProperty("results"); |
| | | 165 | | var suggestions = new List<ExternalLinkSuggestion>(); |
| | | 166 | | |
| | | 167 | | foreach (var item in results.EnumerateArray()) |
| | | 168 | | { |
| | | 169 | | var name = GetString(item, "name"); |
| | | 170 | | if (string.IsNullOrEmpty(name) || |
| | | 171 | | !name.Contains(query, StringComparison.OrdinalIgnoreCase)) |
| | | 172 | | { |
| | | 173 | | continue; |
| | | 174 | | } |
| | | 175 | | |
| | | 176 | | var suggestion = ParseSearchResult(item, strategy); |
| | | 177 | | if (suggestion != null) |
| | | 178 | | suggestions.Add(suggestion); |
| | | 179 | | } |
| | | 180 | | |
| | | 181 | | return suggestions; |
| | | 182 | | } |
| | | 183 | | catch (Exception ex) |
| | | 184 | | { |
| | | 185 | | _logger.LogErrorSanitized(ex, "Open5e search failed for category {Category} query {Query}", |
| | | 186 | | strategy.CategoryKey, query); |
| | | 187 | | return new List<ExternalLinkSuggestion>(); |
| | | 188 | | } |
| | | 189 | | } |
| | | 190 | | |
| | | 191 | | private ExternalLinkSuggestion? ParseSearchResult(JsonElement item, IOpen5eCategoryStrategy strategy) |
| | | 192 | | { |
| | 34 | 193 | | var itemKey = GetString(item, "key"); |
| | 34 | 194 | | var name = GetString(item, "name"); |
| | | 195 | | |
| | 34 | 196 | | if (string.IsNullOrEmpty(itemKey) || string.IsNullOrEmpty(name)) |
| | 1 | 197 | | return null; |
| | | 198 | | |
| | 33 | 199 | | return new ExternalLinkSuggestion |
| | 33 | 200 | | { |
| | 33 | 201 | | Source = Key, |
| | 33 | 202 | | Id = $"{strategy.CategoryKey}/{itemKey}", |
| | 33 | 203 | | Title = name, |
| | 33 | 204 | | Subtitle = strategy.BuildSubtitle(item), |
| | 33 | 205 | | Category = strategy.CategoryKey, |
| | 33 | 206 | | Href = BuildWebUrl(strategy, itemKey) |
| | 33 | 207 | | }; |
| | | 208 | | } |
| | | 209 | | |
| | | 210 | | private static string? BuildWebUrl(IOpen5eCategoryStrategy strategy, string itemKey) => |
| | 46 | 211 | | $"https://open5e.com/{strategy.WebCategory}/{itemKey}"; |
| | | 212 | | |
| | | 213 | | private static (string? category, string? itemKey) ParseId(string id) |
| | | 214 | | { |
| | 17 | 215 | | var slashIndex = id.IndexOf('/'); |
| | 17 | 216 | | if (slashIndex > 0 && slashIndex < id.Length - 1) |
| | 16 | 217 | | return (id[..slashIndex], id[(slashIndex + 1)..]); |
| | 1 | 218 | | return (null, null); |
| | | 219 | | } |
| | | 220 | | |
| | | 221 | | private static IOpen5eCategoryStrategy? FindStrategy(string categoryPart) |
| | | 222 | | { |
| | 9 | 223 | | if (Strategies.TryGetValue(categoryPart, out var exact)) |
| | 7 | 224 | | return exact; |
| | | 225 | | |
| | 2 | 226 | | return Strategies.Values.FirstOrDefault(s => |
| | 2 | 227 | | s.CategoryKey.StartsWith(categoryPart, StringComparison.OrdinalIgnoreCase)); |
| | | 228 | | } |
| | | 229 | | |
| | | 230 | | private ExternalLinkContent CreateEmptyContent(string id) |
| | | 231 | | { |
| | 6 | 232 | | return new ExternalLinkContent |
| | 6 | 233 | | { |
| | 6 | 234 | | Source = Key, |
| | 6 | 235 | | Id = id, |
| | 6 | 236 | | Title = string.Empty, |
| | 6 | 237 | | Kind = string.Empty, |
| | 6 | 238 | | Markdown = string.Empty |
| | 6 | 239 | | }; |
| | | 240 | | } |
| | | 241 | | |
| | | 242 | | private static FrozenDictionary<string, IOpen5eCategoryStrategy> CreateStrategies() |
| | | 243 | | { |
| | 1 | 244 | | var strategies = new IOpen5eCategoryStrategy[] |
| | 1 | 245 | | { |
| | 1 | 246 | | new SpellCategoryStrategy(), |
| | 1 | 247 | | new MonsterCategoryStrategy(), |
| | 1 | 248 | | new MagicItemCategoryStrategy(), |
| | 1 | 249 | | new ConditionCategoryStrategy(), |
| | 1 | 250 | | new BackgroundCategoryStrategy(), |
| | 1 | 251 | | new FeatCategoryStrategy(), |
| | 1 | 252 | | new ClassCategoryStrategy(), |
| | 1 | 253 | | new RaceCategoryStrategy(), |
| | 1 | 254 | | new WeaponCategoryStrategy(), |
| | 1 | 255 | | new ArmorCategoryStrategy() |
| | 1 | 256 | | }; |
| | | 257 | | |
| | 1 | 258 | | return strategies.ToFrozenDictionary(s => s.CategoryKey, StringComparer.OrdinalIgnoreCase); |
| | | 259 | | } |
| | | 260 | | } |