cricinfo-cli-go 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/AGENTS.md +63 -0
  2. package/CONTRIBUTORS.md +75 -0
  3. package/LICENSE +21 -0
  4. package/Makefile +131 -0
  5. package/README.md +130 -0
  6. package/bin/cricinfo.js +44 -0
  7. package/cmd/cricinfo/main.go +15 -0
  8. package/go.mod +10 -0
  9. package/go.sum +10 -0
  10. package/internal/app/app.go +11 -0
  11. package/internal/app/app_test.go +122 -0
  12. package/internal/buildinfo/buildinfo.go +16 -0
  13. package/internal/cli/analysis.go +262 -0
  14. package/internal/cli/analysis_test.go +175 -0
  15. package/internal/cli/competitions.go +154 -0
  16. package/internal/cli/competitions_test.go +165 -0
  17. package/internal/cli/leagues.go +297 -0
  18. package/internal/cli/leagues_test.go +194 -0
  19. package/internal/cli/matches.go +403 -0
  20. package/internal/cli/matches_test.go +413 -0
  21. package/internal/cli/players.go +263 -0
  22. package/internal/cli/players_test.go +384 -0
  23. package/internal/cli/root.go +141 -0
  24. package/internal/cli/search.go +119 -0
  25. package/internal/cli/teams.go +214 -0
  26. package/internal/cli/teams_test.go +192 -0
  27. package/internal/cricinfo/analysis.go +1401 -0
  28. package/internal/cricinfo/analysis_phase15_test.go +267 -0
  29. package/internal/cricinfo/client.go +471 -0
  30. package/internal/cricinfo/client_test.go +280 -0
  31. package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
  32. package/internal/cricinfo/competitions.go +405 -0
  33. package/internal/cricinfo/competitions_phase13_test.go +234 -0
  34. package/internal/cricinfo/coverage_ledger.go +122 -0
  35. package/internal/cricinfo/coverage_ledger_test.go +253 -0
  36. package/internal/cricinfo/decode.go +115 -0
  37. package/internal/cricinfo/decode_test.go +100 -0
  38. package/internal/cricinfo/entity_index.go +618 -0
  39. package/internal/cricinfo/entity_index_test.go +175 -0
  40. package/internal/cricinfo/fixture_matrix.go +243 -0
  41. package/internal/cricinfo/fixture_matrix_test.go +49 -0
  42. package/internal/cricinfo/fixtures_test.go +264 -0
  43. package/internal/cricinfo/historical_hydration.go +1641 -0
  44. package/internal/cricinfo/historical_phase14_test.go +542 -0
  45. package/internal/cricinfo/leagues.go +1210 -0
  46. package/internal/cricinfo/leagues_phase12_test.go +324 -0
  47. package/internal/cricinfo/live_leagues_test.go +169 -0
  48. package/internal/cricinfo/live_matches_test.go +203 -0
  49. package/internal/cricinfo/live_matrix_test.go +118 -0
  50. package/internal/cricinfo/live_players_test.go +122 -0
  51. package/internal/cricinfo/live_search_test.go +86 -0
  52. package/internal/cricinfo/live_smoke_test.go +213 -0
  53. package/internal/cricinfo/live_teams_test.go +104 -0
  54. package/internal/cricinfo/matches.go +1508 -0
  55. package/internal/cricinfo/matches_phase7_test.go +207 -0
  56. package/internal/cricinfo/matches_phase9_test.go +253 -0
  57. package/internal/cricinfo/normalize_entities.go +1727 -0
  58. package/internal/cricinfo/normalize_leagues.go +346 -0
  59. package/internal/cricinfo/players.go +1332 -0
  60. package/internal/cricinfo/players_phase10_test.go +174 -0
  61. package/internal/cricinfo/players_phase11_test.go +373 -0
  62. package/internal/cricinfo/render_contract.go +1088 -0
  63. package/internal/cricinfo/render_phase4_test.go +633 -0
  64. package/internal/cricinfo/renderer.go +1689 -0
  65. package/internal/cricinfo/resolver.go +813 -0
  66. package/internal/cricinfo/resolver_test.go +244 -0
  67. package/internal/cricinfo/teams.go +603 -0
  68. package/internal/cricinfo/teams_phase8_test.go +231 -0
  69. package/internal/cricinfo/testdata/fixtures/README.md +43 -0
  70. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
  71. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
  72. package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
  73. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
  74. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
  75. package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
  76. package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
  77. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
  78. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
  79. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
  80. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
  81. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
  82. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
  83. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
  84. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
  85. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
  86. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
  87. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
  88. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
  89. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
  90. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
  91. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
  92. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
  93. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
  94. package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
  95. package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
  96. package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
  97. package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
  98. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
  99. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
  100. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
  101. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
  102. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
  103. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
  104. package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
  105. package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
  106. package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
  107. package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
  108. package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
  109. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
  110. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
  111. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
  112. package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
  113. package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
  114. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
  115. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
  116. package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
  117. package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
  118. package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
  119. package/internal/cricinfo/types.go +54 -0
  120. package/package.json +51 -0
  121. package/scripts/postinstall.js +153 -0
@@ -0,0 +1,1689 @@
1
+ package cricinfo
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "io"
7
+ "regexp"
8
+ "sort"
9
+ "strings"
10
+ )
11
+
12
+ // RenderOptions controls output behavior for the rendering boundary.
13
+ type RenderOptions struct {
14
+ Format string
15
+ Verbose bool
16
+ AllFields bool
17
+ }
18
+
19
+ // Renderer defines the rendering boundary all commands should use.
20
+ type Renderer interface {
21
+ Render(w io.Writer, result NormalizedResult, opts RenderOptions) error
22
+ }
23
+
24
+ type defaultRenderer struct{}
25
+
26
+ // NewRenderer returns the default rendering implementation.
27
+ func NewRenderer() Renderer {
28
+ return &defaultRenderer{}
29
+ }
30
+
31
+ // Render writes a normalized result using the requested output format.
32
+ func Render(w io.Writer, result NormalizedResult, opts RenderOptions) error {
33
+ return NewRenderer().Render(w, result, opts)
34
+ }
35
+
36
+ func (r *defaultRenderer) Render(w io.Writer, result NormalizedResult, opts RenderOptions) error {
37
+ format := strings.ToLower(strings.TrimSpace(opts.Format))
38
+ if format == "" {
39
+ format = "text"
40
+ }
41
+
42
+ switch format {
43
+ case "text":
44
+ return renderText(w, result, opts)
45
+ case "json":
46
+ return renderJSON(w, result, opts)
47
+ case "jsonl":
48
+ return renderJSONL(w, result, opts)
49
+ default:
50
+ return fmt.Errorf("unsupported render format %q", opts.Format)
51
+ }
52
+ }
53
+
54
+ func renderJSON(w io.Writer, result NormalizedResult, opts RenderOptions) error {
55
+ sanitized, err := sanitizeValue(result, opts.AllFields)
56
+ if err != nil {
57
+ return err
58
+ }
59
+
60
+ encoded, err := json.MarshalIndent(sanitized, "", " ")
61
+ if err != nil {
62
+ return fmt.Errorf("encode json output: %w", err)
63
+ }
64
+
65
+ if _, err := fmt.Fprintln(w, string(encoded)); err != nil {
66
+ return fmt.Errorf("write json output: %w", err)
67
+ }
68
+
69
+ return nil
70
+ }
71
+
72
+ func renderJSONL(w io.Writer, result NormalizedResult, opts RenderOptions) error {
73
+ if len(result.Items) == 0 {
74
+ switch result.Status {
75
+ case ResultStatusEmpty:
76
+ return nil
77
+ case ResultStatusError:
78
+ meta := map[string]any{
79
+ "_meta": map[string]any{
80
+ "kind": result.Kind,
81
+ "status": result.Status,
82
+ },
83
+ }
84
+ if result.Message != "" {
85
+ meta["_meta"].(map[string]any)["message"] = result.Message
86
+ }
87
+ if result.Error != nil {
88
+ meta["_meta"].(map[string]any)["error"] = result.Error
89
+ }
90
+ return writeJSONLLine(w, meta, opts.AllFields)
91
+ default:
92
+ if result.Data != nil {
93
+ return fmt.Errorf("jsonl format requires list results")
94
+ }
95
+ return nil
96
+ }
97
+ }
98
+
99
+ if result.Data != nil {
100
+ return fmt.Errorf("jsonl format requires list results")
101
+ }
102
+
103
+ if result.Status == ResultStatusPartial || len(result.Warnings) > 0 || result.Message != "" {
104
+ meta := map[string]any{
105
+ "_meta": map[string]any{
106
+ "kind": result.Kind,
107
+ "status": result.Status,
108
+ },
109
+ }
110
+ if len(result.Warnings) > 0 {
111
+ meta["_meta"].(map[string]any)["warnings"] = result.Warnings
112
+ }
113
+ if result.Message != "" {
114
+ meta["_meta"].(map[string]any)["message"] = result.Message
115
+ }
116
+ if err := writeJSONLLine(w, meta, opts.AllFields); err != nil {
117
+ return err
118
+ }
119
+ }
120
+
121
+ for _, item := range result.Items {
122
+ if err := writeJSONLLine(w, item, opts.AllFields); err != nil {
123
+ return err
124
+ }
125
+ }
126
+
127
+ return nil
128
+ }
129
+
130
+ func writeJSONLLine(w io.Writer, value any, allFields bool) error {
131
+ sanitized, err := sanitizeValue(value, allFields)
132
+ if err != nil {
133
+ return err
134
+ }
135
+ encoded, err := json.Marshal(sanitized)
136
+ if err != nil {
137
+ return fmt.Errorf("encode jsonl line: %w", err)
138
+ }
139
+ if _, err := fmt.Fprintln(w, string(encoded)); err != nil {
140
+ return fmt.Errorf("write jsonl line: %w", err)
141
+ }
142
+ return nil
143
+ }
144
+
145
+ func renderText(w io.Writer, result NormalizedResult, opts RenderOptions) error {
146
+ lines := make([]string, 0, 16)
147
+ kindTitle := titleize(string(result.Kind))
148
+
149
+ switch result.Status {
150
+ case ResultStatusError:
151
+ message := result.Message
152
+ if message == "" {
153
+ message = "transport error"
154
+ }
155
+ lines = append(lines, fmt.Sprintf("Error (%s): %s", kindTitle, message))
156
+ if result.Error != nil {
157
+ if result.Error.URL != "" {
158
+ lines = append(lines, "URL: "+result.Error.URL)
159
+ }
160
+ if result.Error.StatusCode > 0 {
161
+ lines = append(lines, fmt.Sprintf("Status: %d", result.Error.StatusCode))
162
+ }
163
+ }
164
+ if result.RequestedRef != "" {
165
+ lines = append(lines, "Requested: "+result.RequestedRef)
166
+ }
167
+ return writeTextLines(w, lines)
168
+ case ResultStatusPartial:
169
+ warningLine := "Partial data returned"
170
+ if len(result.Warnings) > 0 {
171
+ warningLine = warningLine + ": " + strings.Join(sanitizeWarningsForText(result.Warnings), "; ")
172
+ }
173
+ lines = append(lines, warningLine)
174
+ }
175
+
176
+ if result.Data != nil {
177
+ if result.Kind == EntityMatchScorecard {
178
+ itemMap, err := toMap(result.Data, opts.AllFields)
179
+ if err != nil {
180
+ return err
181
+ }
182
+ lines = append(lines, "Match Scorecard")
183
+ lines = append(lines, formatMatchScorecard(itemMap)...)
184
+ return writeTextLines(w, lines)
185
+ }
186
+ if result.Kind == EntityTeamLeaders {
187
+ itemMap, err := toMap(result.Data, opts.AllFields)
188
+ if err != nil {
189
+ return err
190
+ }
191
+ lines = append(lines, "Team Leaders")
192
+ lines = append(lines, formatTeamLeaders(itemMap)...)
193
+ return writeTextLines(w, lines)
194
+ }
195
+ if result.Kind == EntityInnings {
196
+ itemMap, err := toMap(result.Data, opts.AllFields)
197
+ if err != nil {
198
+ return err
199
+ }
200
+ lines = append(lines, "Innings")
201
+ lines = append(lines, formatInningsTimelines(itemMap)...)
202
+ return writeTextLines(w, lines)
203
+ }
204
+ if result.Kind == EntityPlayerStats {
205
+ itemMap, err := toMap(result.Data, opts.AllFields)
206
+ if err != nil {
207
+ return err
208
+ }
209
+ lines = append(lines, "Player Statistics")
210
+ lines = append(lines, formatPlayerStatistics(itemMap)...)
211
+ return writeTextLines(w, lines)
212
+ }
213
+ if result.Kind == EntityAnalysisDismiss || result.Kind == EntityAnalysisBowl || result.Kind == EntityAnalysisBat || result.Kind == EntityAnalysisPart {
214
+ itemMap, err := toMap(result.Data, opts.AllFields)
215
+ if err != nil {
216
+ return err
217
+ }
218
+ lines = append(lines, "Analysis")
219
+ lines = append(lines, formatAnalysisView(itemMap)...)
220
+ return writeTextLines(w, lines)
221
+ }
222
+ if result.Kind == EntityPlayer {
223
+ itemMap, err := toMap(result.Data, opts.AllFields)
224
+ if err != nil {
225
+ return err
226
+ }
227
+ lines = append(lines, "Player")
228
+ lines = append(lines, formatPlayerProfile(itemMap)...)
229
+ return writeTextLines(w, lines)
230
+ }
231
+ if result.Kind == EntityPlayerMatch {
232
+ itemMap, err := toMap(result.Data, opts.AllFields)
233
+ if err != nil {
234
+ return err
235
+ }
236
+ lines = append(lines, "Player Match")
237
+ lines = append(lines, formatPlayerMatchView(itemMap)...)
238
+ return writeTextLines(w, lines)
239
+ }
240
+ if result.Kind == EntityCompMetadata {
241
+ itemMap, err := toMap(result.Data, opts.AllFields)
242
+ if err != nil {
243
+ return err
244
+ }
245
+ lines = append(lines, "Competition Metadata")
246
+ lines = append(lines, formatCompetitionMetadata(itemMap)...)
247
+ return writeTextLines(w, lines)
248
+ }
249
+ if result.Kind == EntityMatch || result.Kind == EntityCompetition {
250
+ itemMap, err := toMap(result.Data, opts.AllFields)
251
+ if err != nil {
252
+ return err
253
+ }
254
+ lines = append(lines, kindTitle)
255
+ lines = append(lines, formatMatchView(itemMap)...)
256
+ return writeTextLines(w, lines)
257
+ }
258
+
259
+ itemMap, err := toMap(result.Data, opts.AllFields)
260
+ if err != nil {
261
+ return err
262
+ }
263
+ lines = append(lines, fmt.Sprintf("%s", kindTitle))
264
+ lines = append(lines, formatSingleEntity(itemMap, result.Kind, opts)...)
265
+ return writeTextLines(w, lines)
266
+ }
267
+
268
+ if len(result.Items) == 0 {
269
+ message := result.Message
270
+ if message == "" {
271
+ message = fmt.Sprintf("No %s found.", kindPlural(result.Kind))
272
+ }
273
+ lines = append(lines, sentenceCase(message))
274
+ return writeTextLines(w, lines)
275
+ }
276
+
277
+ lines = append(lines, fmt.Sprintf("%s (%d)", titleize(kindPlural(result.Kind)), len(result.Items)))
278
+ for i, item := range result.Items {
279
+ itemMap, err := toMap(item, opts.AllFields)
280
+ if err != nil {
281
+ return err
282
+ }
283
+ lines = append(lines, fmt.Sprintf("%d. %s", i+1, summarizeEntity(itemMap, result.Kind, opts.Verbose)))
284
+ }
285
+
286
+ return writeTextLines(w, lines)
287
+ }
288
+
289
+ func writeTextLines(w io.Writer, lines []string) error {
290
+ for _, line := range lines {
291
+ if _, err := fmt.Fprintln(w, line); err != nil {
292
+ return fmt.Errorf("write text output: %w", err)
293
+ }
294
+ }
295
+ return nil
296
+ }
297
+
298
+ func sanitizeValue(value any, allFields bool) (any, error) {
299
+ blob, err := json.Marshal(value)
300
+ if err != nil {
301
+ return nil, fmt.Errorf("marshal value: %w", err)
302
+ }
303
+
304
+ var out any
305
+ if err := json.Unmarshal(blob, &out); err != nil {
306
+ return nil, fmt.Errorf("unmarshal value: %w", err)
307
+ }
308
+
309
+ if !allFields {
310
+ removeExtensions(out)
311
+ }
312
+
313
+ return out, nil
314
+ }
315
+
316
+ func removeExtensions(value any) {
317
+ switch typed := value.(type) {
318
+ case map[string]any:
319
+ delete(typed, "extensions")
320
+ for _, child := range typed {
321
+ removeExtensions(child)
322
+ }
323
+ case []any:
324
+ for _, child := range typed {
325
+ removeExtensions(child)
326
+ }
327
+ }
328
+ }
329
+
330
+ func toMap(value any, allFields bool) (map[string]any, error) {
331
+ sanitized, err := sanitizeValue(value, allFields)
332
+ if err != nil {
333
+ return nil, err
334
+ }
335
+ mapped, ok := sanitized.(map[string]any)
336
+ if !ok {
337
+ return nil, fmt.Errorf("render item is not an object")
338
+ }
339
+ return mapped, nil
340
+ }
341
+
342
+ func summarizeEntity(entity map[string]any, kind EntityKind, verbose bool) string {
343
+ switch kind {
344
+ case EntityMatch:
345
+ id := valueString(entity, "id")
346
+ desc := firstNonEmpty(valueString(entity, "shortDescription"), valueString(entity, "description"))
347
+ if desc == "" {
348
+ desc = valueString(entity, "note")
349
+ }
350
+ if desc == "" {
351
+ desc = valueString(entity, "date")
352
+ }
353
+ teams := matchTeamsLabel(entity)
354
+ state := valueString(entity, "matchState")
355
+ score := valueString(entity, "scoreSummary")
356
+ venue := firstNonEmpty(valueString(entity, "venueName"), valueString(entity, "venueSummary"))
357
+ date := valueString(entity, "date")
358
+ if verbose {
359
+ return joinParts(
360
+ id,
361
+ desc,
362
+ teams,
363
+ state,
364
+ score,
365
+ date,
366
+ venue,
367
+ "league "+valueString(entity, "leagueId"),
368
+ "event "+valueString(entity, "eventId"),
369
+ )
370
+ }
371
+ return joinParts(id, desc, teams, state, score, date)
372
+ case EntityMatchScorecard:
373
+ return joinParts(
374
+ fmt.Sprintf("batting %d", len(sliceValue(entity, "battingCards"))),
375
+ fmt.Sprintf("bowling %d", len(sliceValue(entity, "bowlingCards"))),
376
+ fmt.Sprintf("partnerships %d", len(sliceValue(entity, "partnershipCards"))),
377
+ )
378
+ case EntityMatchSituation:
379
+ if data := valueString(entity, "data"); data != "" {
380
+ return joinParts("situation", data)
381
+ }
382
+ return joinParts("situation", valueString(entity, "competitionId"))
383
+ case EntityCompetition:
384
+ return joinParts(
385
+ firstNonEmpty(valueString(entity, "shortDescription"), valueString(entity, "description"), valueString(entity, "id")),
386
+ matchTeamsLabel(entity),
387
+ valueString(entity, "matchState"),
388
+ )
389
+ case EntityCompOfficial, EntityCompBroadcast, EntityCompTicket, EntityCompOdds:
390
+ return joinParts(
391
+ firstNonEmpty(valueString(entity, "displayName"), valueString(entity, "name"), valueString(entity, "text"), valueString(entity, "value"), valueString(entity, "id")),
392
+ valueString(entity, "role"),
393
+ valueString(entity, "type"),
394
+ )
395
+ case EntityCompMetadata:
396
+ return joinParts(
397
+ "officials "+fmt.Sprintf("%d", len(sliceValue(entity, "officials"))),
398
+ "broadcasts "+fmt.Sprintf("%d", len(sliceValue(entity, "broadcasts"))),
399
+ "tickets "+fmt.Sprintf("%d", len(sliceValue(entity, "tickets"))),
400
+ "odds "+fmt.Sprintf("%d", len(sliceValue(entity, "odds"))),
401
+ )
402
+ case EntityPlayer:
403
+ return joinParts(firstNonEmpty(valueString(entity, "displayName"), valueString(entity, "fullName"), valueString(entity, "name")), bracket(valueString(entity, "id")))
404
+ case EntityPlayerStats:
405
+ return joinParts(firstNonEmpty(valueString(entity, "name"), "statistics"), fmt.Sprintf("%d categories", len(sliceValue(entity, "categories"))))
406
+ case EntityPlayerMatch:
407
+ return joinParts(
408
+ firstNonEmpty(valueString(entity, "playerName"), valueString(entity, "playerId")),
409
+ "match "+valueString(entity, "matchId"),
410
+ "bat "+fmt.Sprintf("%d", len(sliceValue(entity, "batting"))),
411
+ "bowl "+fmt.Sprintf("%d", len(sliceValue(entity, "bowling"))),
412
+ )
413
+ case EntityPlayerInnings:
414
+ return joinParts(
415
+ firstNonEmpty(valueString(entity, "playerName"), valueString(entity, "playerId")),
416
+ "innings "+valueString(entity, "inningsNumber")+"/"+valueString(entity, "period"),
417
+ firstNonEmpty(valueString(entity, "teamName"), valueString(entity, "teamId")),
418
+ )
419
+ case EntityPlayerDismissal:
420
+ return joinParts(
421
+ firstNonEmpty(valueString(entity, "dismissalName"), valueString(entity, "dismissalType")),
422
+ firstNonEmpty(valueString(entity, "dismissalCard"), valueString(entity, "fow")),
423
+ valueString(entity, "detailRef"),
424
+ )
425
+ case EntityPlayerDelivery:
426
+ short := firstNonEmpty(valueString(entity, "shortText"), valueString(entity, "text"))
427
+ return joinParts(short, involvementLabel(entity), overBallLabel(entity))
428
+ case EntityNewsArticle:
429
+ return joinParts(firstNonEmpty(valueString(entity, "headline"), valueString(entity, "title"), valueString(entity, "id")), valueString(entity, "published"), valueString(entity, "byline"))
430
+ case EntityTeam:
431
+ name := firstNonEmpty(valueString(entity, "name"), valueString(entity, "shortName"), valueString(entity, "id"))
432
+ return joinParts(name, bracket(valueString(entity, "homeAway")))
433
+ case EntityTeamRoster:
434
+ name := firstNonEmpty(valueString(entity, "displayName"), valueString(entity, "playerId"), valueString(entity, "athleteId"))
435
+ badges := []string{}
436
+ if valueString(entity, "captain") == "true" {
437
+ badges = append(badges, "captain")
438
+ }
439
+ if valueString(entity, "active") == "true" {
440
+ badges = append(badges, "active")
441
+ }
442
+ return joinParts(name, strings.Join(badges, ", "))
443
+ case EntityTeamScore:
444
+ return joinParts(valueString(entity, "displayValue"), valueString(entity, "value"), bracket(valueString(entity, "source")))
445
+ case EntityTeamLeaders:
446
+ return joinParts(valueString(entity, "name"), fmt.Sprintf("%d categories", len(sliceValue(entity, "categories"))))
447
+ case EntityTeamStatistics, EntityTeamRecords:
448
+ return joinParts(firstNonEmpty(valueString(entity, "displayName"), valueString(entity, "name")), fmt.Sprintf("%d stats", len(sliceValue(entity, "stats"))))
449
+ case EntityLeague:
450
+ return joinParts(firstNonEmpty(valueString(entity, "name"), valueString(entity, "id")), bracket(valueString(entity, "slug")))
451
+ case EntitySeason:
452
+ return joinParts(valueString(entity, "id"), valueString(entity, "leagueId"))
453
+ case EntityCalendarDay:
454
+ return joinParts(
455
+ valueString(entity, "date"),
456
+ valueString(entity, "dayType"),
457
+ strings.Join(stringSliceValue(entity, "sections"), ", "),
458
+ )
459
+ case EntitySeasonType:
460
+ return joinParts(
461
+ firstNonEmpty(valueString(entity, "name"), "type "+valueString(entity, "id")),
462
+ "season "+valueString(entity, "seasonId"),
463
+ )
464
+ case EntitySeasonGroup:
465
+ return joinParts(
466
+ firstNonEmpty(valueString(entity, "name"), "group "+valueString(entity, "id")),
467
+ "type "+valueString(entity, "typeId"),
468
+ "season "+valueString(entity, "seasonId"),
469
+ )
470
+ case EntityStandingsGroup:
471
+ return joinParts(valueString(entity, "id"), "season "+valueString(entity, "seasonId"))
472
+ case EntityInnings:
473
+ score := valueString(entity, "score")
474
+ if score == "" {
475
+ score = joinParts(valueString(entity, "runs")+"/"+valueString(entity, "wickets"), valueString(entity, "overs")+" ov")
476
+ }
477
+ return joinParts(
478
+ firstNonEmpty(valueString(entity, "teamName"), valueString(entity, "teamId")),
479
+ "innings "+valueString(entity, "inningsNumber")+"/"+valueString(entity, "period"),
480
+ score,
481
+ fmt.Sprintf("%d wickets", len(sliceValue(entity, "wicketTimeline"))),
482
+ )
483
+ case EntityDeliveryEvent:
484
+ short := firstNonEmpty(valueString(entity, "shortText"), valueString(entity, "text"))
485
+ if short == "" {
486
+ short = joinParts("over "+valueString(entity, "overNumber"), "ball "+valueString(entity, "ballNumber"))
487
+ }
488
+ return short
489
+ case EntityStatCategory:
490
+ return joinParts(firstNonEmpty(valueString(entity, "displayName"), valueString(entity, "name")), fmt.Sprintf("%d stats", len(sliceValue(entity, "stats"))))
491
+ case EntityPartnership:
492
+ return joinParts(
493
+ firstNonEmpty(valueString(entity, "wicketName"), "partnership "+valueString(entity, "id")),
494
+ valueString(entity, "runs")+" runs",
495
+ valueString(entity, "overs")+" ov",
496
+ "innings "+valueString(entity, "inningsId")+"/"+valueString(entity, "period"),
497
+ )
498
+ case EntityFallOfWicket:
499
+ return joinParts(
500
+ "wicket "+valueString(entity, "wicketNumber"),
501
+ valueString(entity, "runs")+"/"+valueString(entity, "wicketNumber"),
502
+ valueString(entity, "wicketOver")+" ov",
503
+ "innings "+valueString(entity, "inningsId")+"/"+valueString(entity, "period"),
504
+ )
505
+ case EntityAnalysisDismiss, EntityAnalysisBowl, EntityAnalysisBat, EntityAnalysisPart:
506
+ return joinParts(
507
+ valueString(entity, "key"),
508
+ valueString(entity, "metric"),
509
+ valueString(entity, "value"),
510
+ )
511
+ default:
512
+ if summary := valueString(entity, "id"); summary != "" {
513
+ return summary
514
+ }
515
+ return "item"
516
+ }
517
+ }
518
+
519
+ func formatSingleEntity(entity map[string]any, kind EntityKind, opts RenderOptions) []string {
520
+ order := []string{}
521
+ switch kind {
522
+ case EntityMatch:
523
+ order = []string{
524
+ "id", "competitionId", "eventId", "leagueId",
525
+ "description", "shortDescription", "matchState",
526
+ "date", "endDate", "venueName", "venueSummary", "scoreSummary",
527
+ "teams",
528
+ }
529
+ case EntityCompetition:
530
+ order = []string{
531
+ "id", "competitionId", "eventId", "leagueId",
532
+ "description", "shortDescription", "matchState",
533
+ "date", "endDate", "venueName", "venueSummary", "scoreSummary",
534
+ "teams",
535
+ }
536
+ case EntityCompOfficial, EntityCompBroadcast, EntityCompTicket, EntityCompOdds:
537
+ order = []string{
538
+ "id", "displayName", "name", "role", "type", "order", "text", "value", "href",
539
+ }
540
+ case EntityCompMetadata:
541
+ order = []string{
542
+ "competition", "officials", "broadcasts", "tickets", "odds",
543
+ }
544
+ case EntityPlayer:
545
+ order = []string{
546
+ "id", "displayName", "fullName", "name", "firstName", "middleName", "lastName",
547
+ "battingName", "fieldingName", "gender", "age", "dateOfBirthDisplay",
548
+ "position", "team", "majorTeams", "debuts", "newsRef",
549
+ }
550
+ case EntityPlayerStats:
551
+ order = []string{"playerId", "name", "abbreviation", "splitId", "categories"}
552
+ case EntityPlayerMatch:
553
+ order = []string{
554
+ "playerId", "playerName", "matchId", "teamId", "teamName",
555
+ "summary", "batting", "bowling", "fielding",
556
+ }
557
+ case EntityPlayerInnings:
558
+ order = []string{
559
+ "playerId", "playerName", "matchId", "teamId", "teamName",
560
+ "inningsNumber", "period", "order", "isBatting", "summary",
561
+ "batting", "bowling", "fielding",
562
+ }
563
+ case EntityPlayerDismissal:
564
+ order = []string{
565
+ "playerId", "playerName", "matchId", "teamId", "teamName",
566
+ "inningsNumber", "period", "wicketNumber", "fow", "over",
567
+ "dismissalName", "dismissalCard", "dismissalType", "dismissalText",
568
+ "ballsFaced", "strikeRate", "batsmanPlayerId", "bowlerPlayerId", "fielderPlayerId",
569
+ "detailRef", "detailShortText",
570
+ }
571
+ case EntityPlayerDelivery:
572
+ order = []string{
573
+ "id", "matchId", "teamId", "period", "overNumber", "ballNumber", "scoreValue",
574
+ "shortText", "dismissalType", "dismissalName", "dismissalCard",
575
+ "batsmanPlayerId", "bowlerPlayerId", "fielderPlayerId",
576
+ "xCoordinate", "yCoordinate", "involvement",
577
+ }
578
+ case EntityNewsArticle:
579
+ order = []string{"id", "headline", "title", "byline", "published", "description", "webUrl"}
580
+ case EntityTeam:
581
+ order = []string{"id", "name", "shortName", "homeAway"}
582
+ case EntityTeamScore:
583
+ order = []string{"teamId", "matchId", "scope", "displayValue", "value", "winner", "source"}
584
+ case EntityLeague:
585
+ order = []string{"id", "name", "slug"}
586
+ case EntitySeason:
587
+ order = []string{"id", "year", "leagueId"}
588
+ case EntityCalendarDay:
589
+ order = []string{"date", "dayType", "sections", "startDate", "endDate", "leagueId"}
590
+ case EntitySeasonType:
591
+ order = []string{"id", "name", "abbreviation", "seasonId", "leagueId", "startDate", "endDate", "hasGroups", "hasStandings", "groupsRef"}
592
+ case EntitySeasonGroup:
593
+ order = []string{"id", "name", "abbreviation", "typeId", "seasonId", "leagueId", "standingsRef"}
594
+ case EntityStandingsGroup:
595
+ order = []string{"id", "seasonId", "groupId"}
596
+ case EntityInnings:
597
+ order = []string{
598
+ "teamName", "teamId", "matchId", "inningsNumber", "period",
599
+ "runs", "wickets", "overs", "score", "description",
600
+ "statisticsRef", "partnershipsRef", "fallOfWicketRef",
601
+ "overTimeline", "wicketTimeline",
602
+ }
603
+ case EntityDeliveryEvent:
604
+ order = []string{
605
+ "id", "period", "overNumber", "ballNumber", "scoreValue", "shortText",
606
+ "playType", "dismissal", "dismissalType", "bbbTimestamp", "xCoordinate", "yCoordinate",
607
+ }
608
+ case EntityMatchScorecard:
609
+ order = []string{"matchId", "competitionId", "eventId", "leagueId", "battingCards", "bowlingCards", "partnershipCards"}
610
+ case EntityMatchSituation:
611
+ order = []string{"matchId", "competitionId", "eventId", "leagueId", "oddsRef", "data"}
612
+ case EntityStatCategory:
613
+ order = []string{"name", "displayName", "abbreviation"}
614
+ case EntityPartnership:
615
+ order = []string{"teamName", "teamId", "inningsId", "period", "wicketNumber", "wicketName", "runs", "overs", "runRate", "batsmen"}
616
+ case EntityFallOfWicket:
617
+ order = []string{"teamName", "teamId", "inningsId", "period", "wicketNumber", "wicketOver", "runs", "runsScored", "ballsFaced", "athleteRef"}
618
+ case EntityAnalysisDismiss, EntityAnalysisBowl, EntityAnalysisBat, EntityAnalysisPart:
619
+ order = []string{
620
+ "command", "metric", "scope", "groupBy", "filters", "rows",
621
+ }
622
+ }
623
+
624
+ lines := make([]string, 0, len(order)+2)
625
+ for _, key := range order {
626
+ value := entity[key]
627
+ if isEmptyValue(value) {
628
+ continue
629
+ }
630
+ lines = append(lines, fmt.Sprintf("%s: %s", key, printableValue(value)))
631
+ }
632
+
633
+ if opts.AllFields {
634
+ if extMap, ok := entity["extensions"].(map[string]any); ok && len(extMap) > 0 {
635
+ keys := mapsKeys(extMap)
636
+ sort.Strings(keys)
637
+ lines = append(lines, "extension fields: "+strings.Join(keys, ", "))
638
+ }
639
+ }
640
+
641
+ if opts.Verbose {
642
+ if ref := valueString(entity, "ref"); ref != "" {
643
+ lines = append(lines, "ref: "+ref)
644
+ }
645
+ }
646
+
647
+ return lines
648
+ }
649
+
650
+ func formatMatchScorecard(entity map[string]any) []string {
651
+ lines := make([]string, 0, 64)
652
+
653
+ if matchID := firstNonEmpty(valueString(entity, "matchId"), valueString(entity, "competitionId")); matchID != "" {
654
+ lines = append(lines, "Match: "+matchID)
655
+ }
656
+
657
+ batting := sliceValue(entity, "battingCards")
658
+ if len(batting) > 0 {
659
+ lines = append(lines, "Batting")
660
+ lines = append(lines, formatBattingCards(batting)...)
661
+ }
662
+
663
+ bowling := sliceValue(entity, "bowlingCards")
664
+ if len(bowling) > 0 {
665
+ lines = append(lines, "Bowling")
666
+ lines = append(lines, formatBowlingCards(bowling)...)
667
+ }
668
+
669
+ partnerships := sliceValue(entity, "partnershipCards")
670
+ if len(partnerships) > 0 {
671
+ lines = append(lines, "Partnerships")
672
+ lines = append(lines, formatPartnershipCards(partnerships)...)
673
+ }
674
+
675
+ if len(batting) == 0 && len(bowling) == 0 && len(partnerships) == 0 {
676
+ lines = append(lines, "No scorecard sections available.")
677
+ }
678
+
679
+ return lines
680
+ }
681
+
682
+ func formatInningsTimelines(entity map[string]any) []string {
683
+ lines := make([]string, 0, 64)
684
+
685
+ if teamName := firstNonEmpty(valueString(entity, "teamName"), valueString(entity, "teamId")); teamName != "" {
686
+ lines = append(lines, "Team: "+teamName)
687
+ }
688
+ if matchID := firstNonEmpty(valueString(entity, "matchId"), valueString(entity, "competitionId")); matchID != "" {
689
+ lines = append(lines, "Match: "+matchID)
690
+ }
691
+
692
+ header := joinParts(
693
+ "Innings "+valueString(entity, "inningsNumber")+"/"+valueString(entity, "period"),
694
+ valueString(entity, "score"),
695
+ )
696
+ if strings.TrimSpace(header) == "" {
697
+ header = joinParts(
698
+ "Innings "+valueString(entity, "inningsNumber")+"/"+valueString(entity, "period"),
699
+ valueString(entity, "runs")+"/"+valueString(entity, "wickets"),
700
+ valueString(entity, "overs")+" ov",
701
+ )
702
+ }
703
+ if header != "" {
704
+ lines = append(lines, header)
705
+ }
706
+
707
+ overs := sliceValue(entity, "overTimeline")
708
+ if len(overs) > 0 {
709
+ lines = append(lines, "Over Timeline")
710
+ for _, rawOver := range overs {
711
+ over, ok := rawOver.(map[string]any)
712
+ if !ok {
713
+ continue
714
+ }
715
+ row := joinParts(
716
+ "Over "+valueString(over, "number"),
717
+ valueString(over, "runs")+" runs",
718
+ valueString(over, "wicketCount")+" wkts",
719
+ )
720
+ if row != "" {
721
+ lines = append(lines, " "+row)
722
+ }
723
+ }
724
+ }
725
+
726
+ wickets := sliceValue(entity, "wicketTimeline")
727
+ if len(wickets) > 0 {
728
+ lines = append(lines, "Wicket Timeline")
729
+ for idx, rawWicket := range wickets {
730
+ wicket, ok := rawWicket.(map[string]any)
731
+ if !ok {
732
+ continue
733
+ }
734
+ row := joinParts(
735
+ "#"+valueString(wicket, "number"),
736
+ valueString(wicket, "fow"),
737
+ valueString(wicket, "over")+" ov",
738
+ firstNonEmpty(valueString(wicket, "shortText"), valueString(wicket, "detailShortText")),
739
+ )
740
+ if row == "" {
741
+ continue
742
+ }
743
+ lines = append(lines, fmt.Sprintf(" %d. %s", idx+1, row))
744
+ if detailRef := valueString(wicket, "detailRef"); detailRef != "" {
745
+ lines = append(lines, " detail: "+detailRef)
746
+ }
747
+ }
748
+ }
749
+
750
+ if len(overs) == 0 && len(wickets) == 0 {
751
+ lines = append(lines, "No period timeline data available.")
752
+ }
753
+
754
+ return lines
755
+ }
756
+
757
+ func formatPlayerProfile(entity map[string]any) []string {
758
+ lines := make([]string, 0, 16)
759
+ if name := firstNonEmpty(valueString(entity, "displayName"), valueString(entity, "fullName"), valueString(entity, "name")); name != "" {
760
+ lines = append(lines, "Name: "+name)
761
+ }
762
+ if playerID := valueString(entity, "id"); playerID != "" {
763
+ lines = append(lines, "Player ID: "+playerID)
764
+ }
765
+ if role := valueString(entity, "position"); role != "" {
766
+ lines = append(lines, "Role: "+role)
767
+ }
768
+ if team := namedValue(entity["team"]); team != "" && !looksLikeRawIdentifier(team) {
769
+ lines = append(lines, "Team: "+team)
770
+ }
771
+ if styles := styleSummary(entity); styles != "" {
772
+ lines = append(lines, "Styles: "+styles)
773
+ }
774
+ if majorTeams := sliceSummary(entity, "majorTeams", 4); majorTeams != "" {
775
+ lines = append(lines, "Major Teams: "+majorTeams)
776
+ }
777
+ if debuts := sliceSummary(entity, "debuts", 4); debuts != "" {
778
+ if strings.HasSuffix(debuts, " items") {
779
+ lines = append(lines, fmt.Sprintf("Debuts: %d matches", len(sliceValue(entity, "debuts"))))
780
+ } else {
781
+ lines = append(lines, "Debuts: "+debuts)
782
+ }
783
+ } else if debuts := sliceValue(entity, "debuts"); len(debuts) > 0 {
784
+ lines = append(lines, fmt.Sprintf("Debuts: %d matches", len(debuts)))
785
+ }
786
+ if born := valueString(entity, "dateOfBirthDisplay"); born != "" {
787
+ lines = append(lines, "Born: "+born)
788
+ }
789
+ return lines
790
+ }
791
+
792
+ func formatPlayerMatchView(entity map[string]any) []string {
793
+ lines := make([]string, 0, 16)
794
+ if player := valueString(entity, "playerName"); player != "" {
795
+ lines = append(lines, "Player: "+player)
796
+ }
797
+ if matchID := valueString(entity, "matchId"); matchID != "" {
798
+ lines = append(lines, "Match: "+matchID)
799
+ }
800
+ if team := valueString(entity, "teamName"); team != "" {
801
+ lines = append(lines, "Team: "+team)
802
+ }
803
+ if summary, ok := entity["summary"].(map[string]any); ok {
804
+ if text := summarizePlayerMatchSummary(summary); text != "" {
805
+ lines = append(lines, text)
806
+ }
807
+ }
808
+ if batting := sliceValue(entity, "batting"); len(batting) > 0 {
809
+ lines = append(lines, fmt.Sprintf("Batting Categories: %d", len(batting)))
810
+ }
811
+ if bowling := sliceValue(entity, "bowling"); len(bowling) > 0 {
812
+ lines = append(lines, fmt.Sprintf("Bowling Categories: %d", len(bowling)))
813
+ }
814
+ if fielding := sliceValue(entity, "fielding"); len(fielding) > 0 {
815
+ lines = append(lines, fmt.Sprintf("Fielding Categories: %d", len(fielding)))
816
+ }
817
+ return lines
818
+ }
819
+
820
+ func formatCompetitionMetadata(entity map[string]any) []string {
821
+ lines := make([]string, 0, 12)
822
+ if competition, ok := entity["competition"].(map[string]any); ok {
823
+ lines = append(lines, "Competition: "+joinParts(
824
+ firstNonEmpty(valueString(competition, "shortDescription"), valueString(competition, "description"), valueString(competition, "id")),
825
+ matchTeamsLabel(competition),
826
+ valueString(competition, "matchState"),
827
+ ))
828
+ }
829
+ if officials := summarizeNamedItems(sliceValue(entity, "officials"), 5); officials != "" {
830
+ lines = append(lines, "Officials: "+officials)
831
+ }
832
+ if broadcasts := summarizeNamedItems(sliceValue(entity, "broadcasts"), 4); broadcasts != "" {
833
+ lines = append(lines, "Broadcasts: "+broadcasts)
834
+ }
835
+ if odds := summarizeNamedItems(sliceValue(entity, "odds"), 3); odds != "" {
836
+ lines = append(lines, "Odds: "+odds)
837
+ }
838
+ if tickets := sliceValue(entity, "tickets"); len(tickets) > 0 {
839
+ lines = append(lines, fmt.Sprintf("Tickets: %d options", len(tickets)))
840
+ }
841
+ return lines
842
+ }
843
+
844
+ func formatMatchView(entity map[string]any) []string {
845
+ lines := make([]string, 0, 16)
846
+ if id := valueString(entity, "id"); id != "" {
847
+ lines = append(lines, "Match: "+id)
848
+ }
849
+ if desc := firstNonEmpty(valueString(entity, "shortDescription"), valueString(entity, "description")); desc != "" {
850
+ lines = append(lines, "Fixture: "+desc)
851
+ }
852
+ if state := valueString(entity, "matchState"); state != "" {
853
+ lines = append(lines, "Status: "+state)
854
+ }
855
+ if score := valueString(entity, "scoreSummary"); score != "" {
856
+ lines = append(lines, "Score: "+score)
857
+ }
858
+ if venue := firstNonEmpty(valueString(entity, "venueName"), valueString(entity, "venueSummary")); venue != "" {
859
+ lines = append(lines, "Venue: "+venue)
860
+ }
861
+ if date := valueString(entity, "date"); date != "" {
862
+ lines = append(lines, "Date: "+date)
863
+ }
864
+ if teams := summarizeNamedItems(sliceValue(entity, "teams"), 4); teams != "" {
865
+ lines = append(lines, "Teams: "+teams)
866
+ }
867
+ return lines
868
+ }
869
+
870
+ func formatPlayerStatistics(entity map[string]any) []string {
871
+ lines := make([]string, 0, 64)
872
+
873
+ if playerID := valueString(entity, "playerId"); playerID != "" {
874
+ lines = append(lines, "Player: "+playerID)
875
+ }
876
+ if header := joinParts(firstNonEmpty(valueString(entity, "name"), "Statistics"), bracket(valueString(entity, "abbreviation"))); header != "" {
877
+ lines = append(lines, header)
878
+ }
879
+
880
+ categories := sliceValue(entity, "categories")
881
+ if len(categories) == 0 {
882
+ lines = append(lines, "No statistics categories available.")
883
+ return lines
884
+ }
885
+
886
+ for _, rawCategory := range categories {
887
+ category, ok := rawCategory.(map[string]any)
888
+ if !ok {
889
+ continue
890
+ }
891
+ categoryName := firstNonEmpty(valueString(category, "displayName"), valueString(category, "name"))
892
+ if categoryName == "" {
893
+ categoryName = "Category"
894
+ }
895
+ lines = append(lines, categoryName)
896
+
897
+ for idx, rawStat := range sliceValue(category, "stats") {
898
+ stat, ok := rawStat.(map[string]any)
899
+ if !ok {
900
+ continue
901
+ }
902
+ row := joinParts(
903
+ firstNonEmpty(valueString(stat, "displayName"), valueString(stat, "name")),
904
+ firstNonEmpty(valueString(stat, "displayValue"), valueString(stat, "value")),
905
+ bracket(valueString(stat, "abbreviation")),
906
+ )
907
+ if row == "" {
908
+ continue
909
+ }
910
+ lines = append(lines, fmt.Sprintf(" %d. %s", idx+1, row))
911
+ }
912
+ }
913
+
914
+ return lines
915
+ }
916
+
917
+ func formatBattingCards(cards []any) []string {
918
+ lines := make([]string, 0, len(cards)*6)
919
+ for _, rawCard := range cards {
920
+ card, ok := rawCard.(map[string]any)
921
+ if !ok {
922
+ continue
923
+ }
924
+ header := joinParts(
925
+ "Innings "+valueString(card, "inningsNumber"),
926
+ valueString(card, "teamName"),
927
+ valueString(card, "runs"),
928
+ valueString(card, "total"),
929
+ )
930
+ if strings.TrimSpace(header) != "" {
931
+ lines = append(lines, header)
932
+ }
933
+ for idx, rawPlayer := range sliceValue(card, "players") {
934
+ player, ok := rawPlayer.(map[string]any)
935
+ if !ok {
936
+ continue
937
+ }
938
+ score := valueString(player, "runs")
939
+ if balls := valueString(player, "ballsFaced"); balls != "" {
940
+ score = strings.TrimSpace(joinParts(score, "("+balls+" balls)"))
941
+ }
942
+ boundary := joinParts("4s "+valueString(player, "fours"), "6s "+valueString(player, "sixes"))
943
+ row := joinParts(valueString(player, "playerName"), score, boundary, valueString(player, "dismissal"))
944
+ if row == "" {
945
+ continue
946
+ }
947
+ lines = append(lines, fmt.Sprintf(" %d. %s", idx+1, row))
948
+ }
949
+ }
950
+ return lines
951
+ }
952
+
953
+ func formatBowlingCards(cards []any) []string {
954
+ lines := make([]string, 0, len(cards)*6)
955
+ for _, rawCard := range cards {
956
+ card, ok := rawCard.(map[string]any)
957
+ if !ok {
958
+ continue
959
+ }
960
+ header := joinParts("Innings "+valueString(card, "inningsNumber"), valueString(card, "teamName"))
961
+ if strings.TrimSpace(header) != "" {
962
+ lines = append(lines, header)
963
+ }
964
+ for idx, rawPlayer := range sliceValue(card, "players") {
965
+ player, ok := rawPlayer.(map[string]any)
966
+ if !ok {
967
+ continue
968
+ }
969
+ figures := joinParts(
970
+ "overs "+valueString(player, "overs"),
971
+ "maidens "+valueString(player, "maidens"),
972
+ "runs "+valueString(player, "conceded"),
973
+ "wkts "+valueString(player, "wickets"),
974
+ "econ "+valueString(player, "economyRate"),
975
+ )
976
+ row := joinParts(valueString(player, "playerName"), figures, valueString(player, "nbw"))
977
+ if row == "" {
978
+ continue
979
+ }
980
+ lines = append(lines, fmt.Sprintf(" %d. %s", idx+1, row))
981
+ }
982
+ }
983
+ return lines
984
+ }
985
+
986
+ func formatPartnershipCards(cards []any) []string {
987
+ lines := make([]string, 0, len(cards)*6)
988
+ for _, rawCard := range cards {
989
+ card, ok := rawCard.(map[string]any)
990
+ if !ok {
991
+ continue
992
+ }
993
+ header := joinParts("Innings "+valueString(card, "inningsNumber"), valueString(card, "teamName"))
994
+ if strings.TrimSpace(header) != "" {
995
+ lines = append(lines, header)
996
+ }
997
+ for idx, rawPlayer := range sliceValue(card, "players") {
998
+ player, ok := rawPlayer.(map[string]any)
999
+ if !ok {
1000
+ continue
1001
+ }
1002
+ runs := valueString(player, "partnershipRuns")
1003
+ runsText := ""
1004
+ if runs != "" {
1005
+ runsText = runs + " runs"
1006
+ }
1007
+ overs := valueString(player, "partnershipOvers")
1008
+ oversText := ""
1009
+ if overs != "" {
1010
+ oversText = overs + " overs"
1011
+ }
1012
+ pair := joinParts(valueString(player, "player1Name"), valueString(player, "player2Name"))
1013
+ detail := joinParts(
1014
+ valueString(player, "partnershipWicketName"),
1015
+ runsText,
1016
+ oversText,
1017
+ )
1018
+ row := joinParts(pair, detail)
1019
+ if row == "" {
1020
+ continue
1021
+ }
1022
+ lines = append(lines, fmt.Sprintf(" %d. %s", idx+1, row))
1023
+ }
1024
+ }
1025
+ return lines
1026
+ }
1027
+
1028
+ func formatTeamLeaders(entity map[string]any) []string {
1029
+ lines := make([]string, 0, 64)
1030
+
1031
+ if teamName := firstNonEmpty(valueString(entity, "teamName"), valueString(entity, "name")); teamName != "" {
1032
+ lines = append(lines, "Team: "+teamName)
1033
+ }
1034
+ if matchID := valueString(entity, "matchId"); matchID != "" {
1035
+ lines = append(lines, "Match: "+matchID)
1036
+ }
1037
+
1038
+ categories := sliceValue(entity, "categories")
1039
+ if len(categories) == 0 {
1040
+ lines = append(lines, "No leaderboard categories available.")
1041
+ return lines
1042
+ }
1043
+
1044
+ batting := make([]string, 0)
1045
+ bowling := make([]string, 0)
1046
+ other := make([]string, 0)
1047
+
1048
+ for _, rawCategory := range categories {
1049
+ category, ok := rawCategory.(map[string]any)
1050
+ if !ok {
1051
+ continue
1052
+ }
1053
+ role := leaderCategoryRole(category)
1054
+ rows := formatTeamLeaderCategory(category, role)
1055
+ switch role {
1056
+ case "batting":
1057
+ batting = append(batting, rows...)
1058
+ case "bowling":
1059
+ bowling = append(bowling, rows...)
1060
+ default:
1061
+ other = append(other, rows...)
1062
+ }
1063
+ }
1064
+
1065
+ if len(batting) > 0 {
1066
+ lines = append(lines, "Batting Leaders")
1067
+ lines = append(lines, batting...)
1068
+ }
1069
+ if len(bowling) > 0 {
1070
+ lines = append(lines, "Bowling Leaders")
1071
+ lines = append(lines, bowling...)
1072
+ }
1073
+ if len(other) > 0 {
1074
+ lines = append(lines, "Other Leaders")
1075
+ lines = append(lines, other...)
1076
+ }
1077
+ if len(batting) == 0 && len(bowling) == 0 && len(other) == 0 {
1078
+ lines = append(lines, "No leaderboard categories available.")
1079
+ }
1080
+
1081
+ return lines
1082
+ }
1083
+
1084
+ func formatAnalysisView(entity map[string]any) []string {
1085
+ lines := make([]string, 0, 64)
1086
+ if command := valueString(entity, "command"); command != "" {
1087
+ lines = append(lines, "Command: "+command)
1088
+ }
1089
+ if metric := valueString(entity, "metric"); metric != "" {
1090
+ lines = append(lines, "Metric: "+metric)
1091
+ }
1092
+
1093
+ scopeMap, _ := entity["scope"].(map[string]any)
1094
+ if scopeMap != nil {
1095
+ mode := valueString(scopeMap, "mode")
1096
+ league := firstNonEmpty(valueString(scopeMap, "requestedLeagueId"), valueString(scopeMap, "leagueName"), valueString(scopeMap, "leagueId"))
1097
+ matchCount := valueString(scopeMap, "matchCount")
1098
+ lines = append(lines, "Scope: "+joinParts(mode, league, "matches "+matchCount))
1099
+ if seasons := sliceValue(scopeMap, "seasons"); len(seasons) > 0 {
1100
+ seasonParts := make([]string, 0, len(seasons))
1101
+ for _, season := range seasons {
1102
+ if asString, ok := season.(string); ok && strings.TrimSpace(asString) != "" {
1103
+ seasonParts = append(seasonParts, strings.TrimSpace(asString))
1104
+ }
1105
+ }
1106
+ if len(seasonParts) > 0 {
1107
+ lines = append(lines, "Seasons: "+strings.Join(seasonParts, ", "))
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ if groupBy := stringSliceValue(entity, "groupBy"); len(groupBy) > 0 {
1113
+ lines = append(lines, "Group By: "+strings.Join(groupBy, ", "))
1114
+ }
1115
+
1116
+ rows := sliceValue(entity, "rows")
1117
+ if len(rows) == 0 {
1118
+ lines = append(lines, "No ranked rows found.")
1119
+ return lines
1120
+ }
1121
+
1122
+ lines = append(lines, "Rows")
1123
+ for i, raw := range rows {
1124
+ row, ok := raw.(map[string]any)
1125
+ if !ok {
1126
+ continue
1127
+ }
1128
+ label := analysisRowLabel(row)
1129
+ line := joinParts(
1130
+ "#"+valueString(row, "rank"),
1131
+ label,
1132
+ valueString(row, "value"),
1133
+ )
1134
+ if count := valueString(row, "count"); count != "" {
1135
+ line = joinParts(line, "count "+count)
1136
+ }
1137
+ if matches := valueString(row, "matches"); matches != "" {
1138
+ line = joinParts(line, "matches "+matches)
1139
+ }
1140
+ if line == "" {
1141
+ continue
1142
+ }
1143
+ if valueString(row, "value") == "0" {
1144
+ continue
1145
+ }
1146
+ lines = append(lines, fmt.Sprintf(" %d. %s", i+1, line))
1147
+ }
1148
+ return lines
1149
+ }
1150
+
1151
+ func formatTeamLeaderCategory(category map[string]any, role string) []string {
1152
+ lines := make([]string, 0, 24)
1153
+ name := firstNonEmpty(valueString(category, "displayName"), valueString(category, "name"))
1154
+ if name == "" {
1155
+ name = "Leaders"
1156
+ }
1157
+ lines = append(lines, name)
1158
+
1159
+ leaders := sliceValue(category, "leaders")
1160
+ for idx, rawLeader := range leaders {
1161
+ leader, ok := rawLeader.(map[string]any)
1162
+ if !ok {
1163
+ continue
1164
+ }
1165
+
1166
+ player := firstNonEmpty(valueString(leader, "athleteName"), valueString(leader, "name"), "Unknown player")
1167
+ primary := valueString(leader, "displayValue")
1168
+ if primary == "" {
1169
+ primary = valueString(leader, "value")
1170
+ }
1171
+
1172
+ score := ""
1173
+ switch role {
1174
+ case "batting":
1175
+ if primary != "" {
1176
+ score = primary + " runs"
1177
+ }
1178
+ case "bowling":
1179
+ if primary != "" {
1180
+ score = primary + " wkts"
1181
+ }
1182
+ default:
1183
+ score = primary
1184
+ }
1185
+
1186
+ extras := []string{}
1187
+ if role == "batting" {
1188
+ if balls := valueString(leader, "balls"); balls != "" {
1189
+ extras = append(extras, balls+" balls")
1190
+ }
1191
+ if fours := valueString(leader, "fours"); fours != "" {
1192
+ extras = append(extras, fours+"x4")
1193
+ }
1194
+ if sixes := valueString(leader, "sixes"); sixes != "" {
1195
+ extras = append(extras, sixes+"x6")
1196
+ }
1197
+ }
1198
+ if role == "bowling" {
1199
+ if overs := valueString(leader, "overs"); overs != "" {
1200
+ extras = append(extras, overs+" ov")
1201
+ }
1202
+ if runs := valueString(leader, "runs"); runs != "" {
1203
+ extras = append(extras, runs+" runs")
1204
+ }
1205
+ if economy := valueString(leader, "economyRate"); economy != "" {
1206
+ extras = append(extras, "econ "+economy)
1207
+ }
1208
+ }
1209
+
1210
+ row := joinParts(player, score, strings.Join(extras, ", "))
1211
+ if row == "" {
1212
+ continue
1213
+ }
1214
+ lines = append(lines, fmt.Sprintf(" %d. %s", idx+1, row))
1215
+ }
1216
+
1217
+ return lines
1218
+ }
1219
+
1220
+ func leaderCategoryRole(category map[string]any) string {
1221
+ name := strings.ToLower(strings.TrimSpace(firstNonEmpty(valueString(category, "name"), valueString(category, "displayName"))))
1222
+ if strings.Contains(name, "run") || strings.Contains(name, "bat") {
1223
+ return "batting"
1224
+ }
1225
+ if strings.Contains(name, "wicket") || strings.Contains(name, "bowl") {
1226
+ return "bowling"
1227
+ }
1228
+ return "other"
1229
+ }
1230
+
1231
+ func analysisRowLabel(row map[string]any) string {
1232
+ if row == nil {
1233
+ return ""
1234
+ }
1235
+ parts := make([]string, 0, 4)
1236
+ if player := valueString(row, "playerName"); player != "" {
1237
+ parts = append(parts, player)
1238
+ }
1239
+ if team := valueString(row, "teamName"); team != "" {
1240
+ parts = append(parts, team)
1241
+ }
1242
+ if dismissal := valueString(row, "dismissalType"); dismissal != "" {
1243
+ parts = append(parts, dismissal)
1244
+ }
1245
+ innings := valueString(row, "inningsNumber")
1246
+ period := valueString(row, "period")
1247
+ if innings != "" && innings != "0" {
1248
+ if period != "" && period != "0" {
1249
+ parts = append(parts, innings+"/"+period)
1250
+ } else {
1251
+ parts = append(parts, innings)
1252
+ }
1253
+ }
1254
+ if len(parts) > 0 {
1255
+ return strings.Join(parts, " | ")
1256
+ }
1257
+ if key := valueString(row, "key"); key != "" {
1258
+ return sanitizeAnalysisKey(key)
1259
+ }
1260
+ return "row"
1261
+ }
1262
+
1263
+ func sanitizeAnalysisKey(key string) string {
1264
+ key = strings.TrimSpace(key)
1265
+ if key == "" {
1266
+ return ""
1267
+ }
1268
+ segments := strings.Split(key, "|")
1269
+ values := make([]string, 0, len(segments))
1270
+ for _, segment := range segments {
1271
+ segment = strings.TrimSpace(segment)
1272
+ if segment == "" {
1273
+ continue
1274
+ }
1275
+ _, value, ok := strings.Cut(segment, "=")
1276
+ if !ok {
1277
+ continue
1278
+ }
1279
+ value = sanitizeWarningText(value)
1280
+ if value == "" || looksLikeRawIdentifier(value) {
1281
+ continue
1282
+ }
1283
+ values = append(values, value)
1284
+ }
1285
+ if len(values) == 0 {
1286
+ return "row"
1287
+ }
1288
+ return strings.Join(values, " | ")
1289
+ }
1290
+
1291
+ func looksLikeRawIdentifier(value string) bool {
1292
+ value = strings.TrimSpace(strings.ToLower(value))
1293
+ if value == "" {
1294
+ return true
1295
+ }
1296
+ if strings.Contains(value, "/") || strings.Contains(value, "http://") || strings.Contains(value, "https://") {
1297
+ return true
1298
+ }
1299
+ for _, r := range value {
1300
+ if r < '0' || r > '9' {
1301
+ return false
1302
+ }
1303
+ }
1304
+ return true
1305
+ }
1306
+
1307
+ var (
1308
+ urlLikeTokenPattern = regexp.MustCompile(`https?://\S+`)
1309
+ htmlTagPattern = regexp.MustCompile(`<[^>]*>`)
1310
+ internalPathPattern = regexp.MustCompile(`(?:https?://[^\s]+)?/v2/sports/cricket[^\s]*`)
1311
+ spaceCollapsePattern = regexp.MustCompile(`\s+`)
1312
+ )
1313
+
1314
+ func sanitizeWarningsForText(warnings []string) []string {
1315
+ out := make([]string, 0, len(warnings))
1316
+ for _, warning := range warnings {
1317
+ cleaned := sanitizeWarningText(warning)
1318
+ if cleaned == "" {
1319
+ continue
1320
+ }
1321
+ out = append(out, cleaned)
1322
+ }
1323
+ if len(out) == 0 {
1324
+ return []string{"partial data"}
1325
+ }
1326
+ return out
1327
+ }
1328
+
1329
+ func sanitizeWarningText(raw string) string {
1330
+ raw = strings.TrimSpace(raw)
1331
+ if raw == "" {
1332
+ return ""
1333
+ }
1334
+ cleaned := htmlTagPattern.ReplaceAllString(raw, " ")
1335
+ cleaned = internalPathPattern.ReplaceAllString(cleaned, " ")
1336
+ cleaned = urlLikeTokenPattern.ReplaceAllString(cleaned, " ")
1337
+ cleaned = strings.ReplaceAll(cleaned, `""`, "")
1338
+ lowered := strings.ToLower(cleaned)
1339
+ if strings.Contains(lowered, "backend fetch failed") || strings.Contains(lowered, "context deadline exceeded") {
1340
+ return "upstream request failed"
1341
+ }
1342
+ cleaned = spaceCollapsePattern.ReplaceAllString(cleaned, " ")
1343
+ return strings.TrimSpace(cleaned)
1344
+ }
1345
+
1346
+ func overBallLabel(entity map[string]any) string {
1347
+ over := valueString(entity, "overNumber")
1348
+ ball := valueString(entity, "ballNumber")
1349
+ if over == "" {
1350
+ return ""
1351
+ }
1352
+ if ball == "" {
1353
+ return "over " + over
1354
+ }
1355
+ return "over " + over + "." + ball
1356
+ }
1357
+
1358
+ func involvementLabel(entity map[string]any) string {
1359
+ roles := stringSliceValue(entity, "involvement")
1360
+ if len(roles) == 0 {
1361
+ return ""
1362
+ }
1363
+ if len(roles) > 1 {
1364
+ filtered := make([]string, 0, len(roles))
1365
+ for _, role := range roles {
1366
+ if role == "involved" {
1367
+ continue
1368
+ }
1369
+ filtered = append(filtered, role)
1370
+ }
1371
+ if len(filtered) > 0 {
1372
+ roles = filtered
1373
+ }
1374
+ }
1375
+ return strings.Join(roles, ", ")
1376
+ }
1377
+
1378
+ func sliceSummary(entity map[string]any, key string, limit int) string {
1379
+ return summarizeNamedItems(sliceValue(entity, key), limit)
1380
+ }
1381
+
1382
+ func summarizeNamedItems(items []any, limit int) string {
1383
+ if len(items) == 0 {
1384
+ return ""
1385
+ }
1386
+ parts := make([]string, 0, minInt(len(items), limit))
1387
+ for _, raw := range items {
1388
+ mapped, ok := raw.(map[string]any)
1389
+ if !ok {
1390
+ continue
1391
+ }
1392
+ name := firstNonEmpty(
1393
+ valueString(mapped, "displayName"),
1394
+ valueString(mapped, "fullName"),
1395
+ valueString(mapped, "shortName"),
1396
+ valueString(mapped, "name"),
1397
+ valueString(mapped, "headline"),
1398
+ valueString(mapped, "title"),
1399
+ )
1400
+ if name == "" {
1401
+ name = namedValue(mapped)
1402
+ }
1403
+ if name == "" {
1404
+ continue
1405
+ }
1406
+ parts = append(parts, name)
1407
+ if len(parts) >= limit {
1408
+ break
1409
+ }
1410
+ }
1411
+ if len(parts) == 0 {
1412
+ return fmt.Sprintf("%d items", len(items))
1413
+ }
1414
+ if len(items) > len(parts) {
1415
+ return strings.Join(parts, ", ") + fmt.Sprintf(" (+%d more)", len(items)-len(parts))
1416
+ }
1417
+ return strings.Join(parts, ", ")
1418
+ }
1419
+
1420
+ func namedValue(raw any) string {
1421
+ mapped, ok := raw.(map[string]any)
1422
+ if !ok || mapped == nil {
1423
+ return ""
1424
+ }
1425
+ name := firstNonEmpty(
1426
+ valueString(mapped, "displayName"),
1427
+ valueString(mapped, "fullName"),
1428
+ valueString(mapped, "shortName"),
1429
+ valueString(mapped, "name"),
1430
+ )
1431
+ if name != "" {
1432
+ return name
1433
+ }
1434
+ id := valueString(mapped, "id")
1435
+ if looksLikeRawIdentifier(id) {
1436
+ return ""
1437
+ }
1438
+ return id
1439
+ }
1440
+
1441
+ func styleSummary(entity map[string]any) string {
1442
+ items := sliceValue(entity, "styles")
1443
+ if len(items) == 0 {
1444
+ return ""
1445
+ }
1446
+ parts := make([]string, 0, len(items))
1447
+ for _, raw := range items {
1448
+ mapped, ok := raw.(map[string]any)
1449
+ if !ok {
1450
+ continue
1451
+ }
1452
+ name := firstNonEmpty(valueString(mapped, "description"), valueString(mapped, "shortDescription"), valueString(mapped, "type"))
1453
+ if name == "" {
1454
+ continue
1455
+ }
1456
+ parts = append(parts, name)
1457
+ }
1458
+ if len(parts) == 0 {
1459
+ return fmt.Sprintf("%d styles", len(items))
1460
+ }
1461
+ return strings.Join(parts, ", ")
1462
+ }
1463
+
1464
+ func summarizePlayerMatchSummary(summary map[string]any) string {
1465
+ runs := valueString(summary, "runs")
1466
+ balls := valueString(summary, "ballsFaced")
1467
+ dismissal := valueString(summary, "dismissalName")
1468
+ strikeRate := valueString(summary, "strikeRate")
1469
+ parts := []string{
1470
+ nonEmptyRunsBalls(runs, balls),
1471
+ nonEmptyLabel("dismissal", dismissal),
1472
+ nonEmptyLabel("SR", strikeRate),
1473
+ nonEmptyLabel("econ", valueString(summary, "economyRate")),
1474
+ nonEmptyLabel("dots", valueString(summary, "dots")),
1475
+ }
1476
+ text := joinParts(parts...)
1477
+ if text == "" {
1478
+ return ""
1479
+ }
1480
+ return "Summary: " + text
1481
+ }
1482
+
1483
+ func nonEmptyRunsBalls(runs, balls string) string {
1484
+ if runs == "" && balls == "" {
1485
+ return ""
1486
+ }
1487
+ if runs != "" && balls != "" {
1488
+ return runs + " runs off " + balls
1489
+ }
1490
+ if runs != "" {
1491
+ return runs + " runs"
1492
+ }
1493
+ return balls + " balls"
1494
+ }
1495
+
1496
+ func nonEmptyLabel(label, value string) string {
1497
+ value = strings.TrimSpace(value)
1498
+ if value == "" {
1499
+ return ""
1500
+ }
1501
+ return label + " " + value
1502
+ }
1503
+
1504
+ func mapsKeys(m map[string]any) []string {
1505
+ keys := make([]string, 0, len(m))
1506
+ for key := range m {
1507
+ keys = append(keys, key)
1508
+ }
1509
+ return keys
1510
+ }
1511
+
1512
+ func valueString(m map[string]any, key string) string {
1513
+ value, ok := m[key]
1514
+ if !ok || value == nil {
1515
+ return ""
1516
+ }
1517
+ switch typed := value.(type) {
1518
+ case string:
1519
+ return strings.TrimSpace(typed)
1520
+ case float64:
1521
+ if typed == float64(int64(typed)) {
1522
+ return fmt.Sprintf("%d", int64(typed))
1523
+ }
1524
+ return fmt.Sprintf("%.2f", typed)
1525
+ case bool:
1526
+ return fmt.Sprintf("%t", typed)
1527
+ case map[string]any:
1528
+ return firstNonEmpty(
1529
+ valueString(typed, "displayName"),
1530
+ valueString(typed, "fullName"),
1531
+ valueString(typed, "shortName"),
1532
+ valueString(typed, "name"),
1533
+ valueString(typed, "headline"),
1534
+ valueString(typed, "title"),
1535
+ valueString(typed, "id"),
1536
+ )
1537
+ case []any:
1538
+ return fmt.Sprintf("%d items", len(typed))
1539
+ default:
1540
+ return strings.TrimSpace(fmt.Sprintf("%v", typed))
1541
+ }
1542
+ }
1543
+
1544
+ func sliceValue(m map[string]any, key string) []any {
1545
+ value, ok := m[key]
1546
+ if !ok || value == nil {
1547
+ return nil
1548
+ }
1549
+ raw, ok := value.([]any)
1550
+ if !ok {
1551
+ return nil
1552
+ }
1553
+ return raw
1554
+ }
1555
+
1556
+ func stringSliceValue(m map[string]any, key string) []string {
1557
+ raw := sliceValue(m, key)
1558
+ if len(raw) == 0 {
1559
+ return nil
1560
+ }
1561
+ out := make([]string, 0, len(raw))
1562
+ for _, item := range raw {
1563
+ asString, ok := item.(string)
1564
+ if !ok {
1565
+ continue
1566
+ }
1567
+ asString = strings.TrimSpace(asString)
1568
+ if asString == "" {
1569
+ continue
1570
+ }
1571
+ out = append(out, asString)
1572
+ }
1573
+ return out
1574
+ }
1575
+
1576
+ func printableValue(value any) string {
1577
+ switch typed := value.(type) {
1578
+ case string:
1579
+ return typed
1580
+ case float64:
1581
+ if typed == float64(int64(typed)) {
1582
+ return fmt.Sprintf("%d", int64(typed))
1583
+ }
1584
+ return fmt.Sprintf("%.2f", typed)
1585
+ case []any:
1586
+ return fmt.Sprintf("%d items", len(typed))
1587
+ case map[string]any:
1588
+ keys := mapsKeys(typed)
1589
+ sort.Strings(keys)
1590
+ if len(keys) > 5 {
1591
+ keys = keys[:5]
1592
+ }
1593
+ return "{" + strings.Join(keys, ", ") + "}"
1594
+ default:
1595
+ return fmt.Sprintf("%v", typed)
1596
+ }
1597
+ }
1598
+
1599
+ func isEmptyValue(value any) bool {
1600
+ if value == nil {
1601
+ return true
1602
+ }
1603
+ switch typed := value.(type) {
1604
+ case string:
1605
+ return strings.TrimSpace(typed) == ""
1606
+ case []any:
1607
+ return len(typed) == 0
1608
+ case map[string]any:
1609
+ return len(typed) == 0
1610
+ default:
1611
+ return false
1612
+ }
1613
+ }
1614
+
1615
+ func joinParts(parts ...string) string {
1616
+ trimmed := make([]string, 0, len(parts))
1617
+ for _, part := range parts {
1618
+ part = strings.TrimSpace(part)
1619
+ if part == "" {
1620
+ continue
1621
+ }
1622
+ trimmed = append(trimmed, part)
1623
+ }
1624
+ return strings.Join(trimmed, " - ")
1625
+ }
1626
+
1627
+ func firstNonEmpty(values ...string) string {
1628
+ for _, value := range values {
1629
+ value = strings.TrimSpace(value)
1630
+ if value != "" {
1631
+ return value
1632
+ }
1633
+ }
1634
+ return ""
1635
+ }
1636
+
1637
+ func bracket(value string) string {
1638
+ value = strings.TrimSpace(value)
1639
+ if value == "" {
1640
+ return ""
1641
+ }
1642
+ return "(" + value + ")"
1643
+ }
1644
+
1645
+ func titleize(value string) string {
1646
+ value = strings.ReplaceAll(value, "_", " ")
1647
+ parts := strings.Fields(value)
1648
+ for i := range parts {
1649
+ parts[i] = strings.Title(parts[i])
1650
+ }
1651
+ return strings.Join(parts, " ")
1652
+ }
1653
+
1654
+ func sentenceCase(value string) string {
1655
+ value = strings.TrimSpace(value)
1656
+ if value == "" {
1657
+ return ""
1658
+ }
1659
+ value = strings.TrimSuffix(value, ".")
1660
+ if len(value) == 1 {
1661
+ return strings.ToUpper(value) + "."
1662
+ }
1663
+ return strings.ToUpper(value[:1]) + value[1:] + "."
1664
+ }
1665
+
1666
+ func matchTeamsLabel(entity map[string]any) string {
1667
+ teams := sliceValue(entity, "teams")
1668
+ if len(teams) == 0 {
1669
+ return ""
1670
+ }
1671
+
1672
+ parts := make([]string, 0, len(teams))
1673
+ for _, raw := range teams {
1674
+ team, ok := raw.(map[string]any)
1675
+ if !ok {
1676
+ continue
1677
+ }
1678
+ name := firstNonEmpty(valueString(team, "shortName"), valueString(team, "name"), valueString(team, "id"))
1679
+ if name == "" {
1680
+ continue
1681
+ }
1682
+ parts = append(parts, name)
1683
+ }
1684
+
1685
+ if len(parts) == 0 {
1686
+ return ""
1687
+ }
1688
+ return strings.Join(parts, " vs ")
1689
+ }