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,618 @@
1
+ package cricinfo
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "sort"
10
+ "strings"
11
+ "sync"
12
+ "time"
13
+ )
14
+
15
+ const (
16
+ entityIndexVersion = 1
17
+ entityIndexFileName = "entity-index-v1.json"
18
+ )
19
+
20
+ var ErrMissingEntityID = errors.New("entity id is required")
21
+
22
+ // IndexedEntity stores a lightweight searchable record for a known Cricinfo entity.
23
+ type IndexedEntity struct {
24
+ Kind EntityKind `json:"kind"`
25
+ ID string `json:"id"`
26
+ Ref string `json:"ref,omitempty"`
27
+ Name string `json:"name,omitempty"`
28
+ ShortName string `json:"shortName,omitempty"`
29
+ LeagueID string `json:"leagueId,omitempty"`
30
+ EventID string `json:"eventId,omitempty"`
31
+ MatchID string `json:"matchId,omitempty"`
32
+ UpdatedAt time.Time `json:"updatedAt"`
33
+ Aliases []string `json:"aliases,omitempty"`
34
+ SourceRefs []string `json:"sourceRefs,omitempty"`
35
+ }
36
+
37
+ // SearchContext improves ranking using currently selected domain context.
38
+ type SearchContext struct {
39
+ PreferredLeagueID string
40
+ PreferredMatchID string
41
+ }
42
+
43
+ type indexFileState struct {
44
+ Version int `json:"version"`
45
+ SavedAt time.Time `json:"savedAt"`
46
+ LastEventsSeedAt time.Time `json:"lastEventsSeedAt,omitempty"`
47
+ HydratedRefs map[string]time.Time `json:"hydratedRefs,omitempty"`
48
+ Entities []IndexedEntity `json:"entities"`
49
+ }
50
+
51
+ // EntityIndex provides alias-backed search over cached entities.
52
+ type EntityIndex struct {
53
+ mu sync.RWMutex
54
+
55
+ path string
56
+
57
+ lastEventsSeedAt time.Time
58
+ hydratedRefs map[string]time.Time
59
+
60
+ entitiesByKey map[string]IndexedEntity
61
+ aliasesByKey map[string]map[string]struct{}
62
+ dirty bool
63
+ }
64
+
65
+ // OpenEntityIndex loads a file-backed index (or creates a new empty index).
66
+ func OpenEntityIndex(path string) (*EntityIndex, error) {
67
+ resolved := strings.TrimSpace(path)
68
+ if resolved == "" {
69
+ defaultPath, err := DefaultEntityIndexPath()
70
+ if err != nil {
71
+ return nil, err
72
+ }
73
+ resolved = defaultPath
74
+ }
75
+
76
+ idx := &EntityIndex{
77
+ path: resolved,
78
+ hydratedRefs: map[string]time.Time{},
79
+ entitiesByKey: map[string]IndexedEntity{},
80
+ aliasesByKey: map[string]map[string]struct{}{},
81
+ }
82
+
83
+ blob, err := os.ReadFile(resolved)
84
+ if err != nil {
85
+ if errors.Is(err, os.ErrNotExist) {
86
+ return idx, nil
87
+ }
88
+ return nil, fmt.Errorf("read entity index %q: %w", resolved, err)
89
+ }
90
+ if len(strings.TrimSpace(string(blob))) == 0 {
91
+ return idx, nil
92
+ }
93
+
94
+ var state indexFileState
95
+ if err := json.Unmarshal(blob, &state); err != nil {
96
+ return nil, fmt.Errorf("decode entity index %q: %w", resolved, err)
97
+ }
98
+
99
+ for _, entity := range state.Entities {
100
+ idx.upsertNoLock(entity)
101
+ }
102
+ idx.lastEventsSeedAt = state.LastEventsSeedAt
103
+ for rawRef, seenAt := range state.HydratedRefs {
104
+ ref := normalizeRef(rawRef)
105
+ if ref == "" {
106
+ continue
107
+ }
108
+ idx.hydratedRefs[ref] = seenAt
109
+ }
110
+
111
+ idx.dirty = false
112
+ return idx, nil
113
+ }
114
+
115
+ // DefaultEntityIndexPath returns the default cache location for the index file.
116
+ func DefaultEntityIndexPath() (string, error) {
117
+ cacheDir, err := os.UserCacheDir()
118
+ if err != nil {
119
+ return "", fmt.Errorf("resolve user cache dir: %w", err)
120
+ }
121
+ return filepath.Join(cacheDir, "cricinfo-cli", entityIndexFileName), nil
122
+ }
123
+
124
+ // Persist writes the index to disk if any mutations have occurred.
125
+ func (i *EntityIndex) Persist() error {
126
+ i.mu.RLock()
127
+ if !i.dirty {
128
+ i.mu.RUnlock()
129
+ return nil
130
+ }
131
+
132
+ state := indexFileState{
133
+ Version: entityIndexVersion,
134
+ SavedAt: time.Now().UTC(),
135
+ LastEventsSeedAt: i.lastEventsSeedAt,
136
+ HydratedRefs: map[string]time.Time{},
137
+ Entities: make([]IndexedEntity, 0, len(i.entitiesByKey)),
138
+ }
139
+
140
+ for ref, seenAt := range i.hydratedRefs {
141
+ state.HydratedRefs[ref] = seenAt
142
+ }
143
+ for _, entity := range i.entitiesByKey {
144
+ state.Entities = append(state.Entities, entity)
145
+ }
146
+ i.mu.RUnlock()
147
+
148
+ sort.Slice(state.Entities, func(a, b int) bool {
149
+ if state.Entities[a].Kind != state.Entities[b].Kind {
150
+ return state.Entities[a].Kind < state.Entities[b].Kind
151
+ }
152
+ return state.Entities[a].ID < state.Entities[b].ID
153
+ })
154
+
155
+ blob, err := json.MarshalIndent(state, "", " ")
156
+ if err != nil {
157
+ return fmt.Errorf("encode entity index: %w", err)
158
+ }
159
+
160
+ if err := os.MkdirAll(filepath.Dir(i.path), 0o755); err != nil {
161
+ return fmt.Errorf("create index directory: %w", err)
162
+ }
163
+
164
+ tmpPath := i.path + ".tmp"
165
+ if err := os.WriteFile(tmpPath, blob, 0o644); err != nil {
166
+ return fmt.Errorf("write temp entity index: %w", err)
167
+ }
168
+ if err := os.Rename(tmpPath, i.path); err != nil {
169
+ return fmt.Errorf("replace entity index: %w", err)
170
+ }
171
+
172
+ i.mu.Lock()
173
+ i.dirty = false
174
+ i.mu.Unlock()
175
+ return nil
176
+ }
177
+
178
+ // Upsert inserts or updates an entity record.
179
+ func (i *EntityIndex) Upsert(entity IndexedEntity) error {
180
+ i.mu.Lock()
181
+ defer i.mu.Unlock()
182
+ return i.upsertNoLock(entity)
183
+ }
184
+
185
+ // UpsertMany inserts or updates many entity records.
186
+ func (i *EntityIndex) UpsertMany(entities []IndexedEntity) error {
187
+ i.mu.Lock()
188
+ defer i.mu.Unlock()
189
+ for _, entity := range entities {
190
+ if err := i.upsertNoLock(entity); err != nil {
191
+ return err
192
+ }
193
+ }
194
+ return nil
195
+ }
196
+
197
+ func (i *EntityIndex) upsertNoLock(entity IndexedEntity) error {
198
+ entity.Kind = EntityKind(strings.TrimSpace(string(entity.Kind)))
199
+ entity.ID = strings.TrimSpace(entity.ID)
200
+ if entity.Kind == "" {
201
+ return fmt.Errorf("entity kind is required")
202
+ }
203
+ if entity.ID == "" {
204
+ return ErrMissingEntityID
205
+ }
206
+
207
+ entity.Ref = normalizeRef(entity.Ref)
208
+ entity.Name = strings.TrimSpace(entity.Name)
209
+ entity.ShortName = strings.TrimSpace(entity.ShortName)
210
+ entity.LeagueID = strings.TrimSpace(entity.LeagueID)
211
+ entity.EventID = strings.TrimSpace(entity.EventID)
212
+ entity.MatchID = strings.TrimSpace(entity.MatchID)
213
+ if entity.UpdatedAt.IsZero() {
214
+ entity.UpdatedAt = time.Now().UTC()
215
+ }
216
+
217
+ key := entityIndexKey(entity.Kind, entity.ID)
218
+ existing, exists := i.entitiesByKey[key]
219
+ if exists {
220
+ entity = mergeIndexedEntity(existing, entity)
221
+ }
222
+
223
+ entity.Aliases = mergeAliasSlices(existing.Aliases, entity.Aliases, generateDefaultAliases(entity))
224
+ entity.SourceRefs = mergeAliasSlices(existing.SourceRefs, entity.SourceRefs, []string{entity.Ref})
225
+
226
+ i.entitiesByKey[key] = entity
227
+ i.aliasesByKey[key] = aliasSet(entity.Aliases)
228
+ i.dirty = true
229
+ return nil
230
+ }
231
+
232
+ // FindByID returns a cached entity by kind/id.
233
+ func (i *EntityIndex) FindByID(kind EntityKind, id string) (IndexedEntity, bool) {
234
+ i.mu.RLock()
235
+ defer i.mu.RUnlock()
236
+
237
+ id = strings.TrimSpace(id)
238
+ if id == "" {
239
+ return IndexedEntity{}, false
240
+ }
241
+ entity, ok := i.entitiesByKey[entityIndexKey(kind, id)]
242
+ return entity, ok
243
+ }
244
+
245
+ // FindByRef returns a cached entity by exact canonical ref.
246
+ func (i *EntityIndex) FindByRef(kind EntityKind, ref string) (IndexedEntity, bool) {
247
+ i.mu.RLock()
248
+ defer i.mu.RUnlock()
249
+
250
+ normalized := normalizeRef(ref)
251
+ if normalized == "" {
252
+ return IndexedEntity{}, false
253
+ }
254
+
255
+ for _, entity := range i.entitiesByKey {
256
+ if entity.Kind != kind {
257
+ continue
258
+ }
259
+ if normalizeRef(entity.Ref) == normalized {
260
+ return entity, true
261
+ }
262
+ for _, sourceRef := range entity.SourceRefs {
263
+ if normalizeRef(sourceRef) == normalized {
264
+ return entity, true
265
+ }
266
+ }
267
+ }
268
+
269
+ return IndexedEntity{}, false
270
+ }
271
+
272
+ // Search performs exact/fuzzy alias lookup for a single entity family.
273
+ func (i *EntityIndex) Search(kind EntityKind, query string, limit int, context SearchContext) []IndexedEntity {
274
+ i.mu.RLock()
275
+ defer i.mu.RUnlock()
276
+
277
+ if limit <= 0 {
278
+ limit = 10
279
+ }
280
+
281
+ queryNormalized := normalizeAlias(query)
282
+ queryTokens := strings.Fields(queryNormalized)
283
+
284
+ type scored struct {
285
+ entity IndexedEntity
286
+ score int
287
+ }
288
+
289
+ matches := make([]scored, 0)
290
+ for key, entity := range i.entitiesByKey {
291
+ if entity.Kind != kind {
292
+ continue
293
+ }
294
+
295
+ score := 0
296
+ if queryNormalized == "" {
297
+ score = 10
298
+ } else {
299
+ aliases := i.aliasesByKey[key]
300
+ for alias := range aliases {
301
+ score = maxInt(score, aliasMatchScore(alias, queryNormalized, queryTokens))
302
+ }
303
+ }
304
+
305
+ if score == 0 {
306
+ continue
307
+ }
308
+ if context.PreferredLeagueID != "" && entity.LeagueID == context.PreferredLeagueID {
309
+ score += 200
310
+ }
311
+ if context.PreferredMatchID != "" && entity.MatchID == context.PreferredMatchID {
312
+ score += 500
313
+ }
314
+
315
+ matches = append(matches, scored{entity: entity, score: score})
316
+ }
317
+
318
+ if preferredMatchID := strings.TrimSpace(context.PreferredMatchID); preferredMatchID != "" {
319
+ matchScoped := make([]scored, 0, len(matches))
320
+ for _, candidate := range matches {
321
+ if strings.TrimSpace(candidate.entity.MatchID) == preferredMatchID {
322
+ matchScoped = append(matchScoped, candidate)
323
+ }
324
+ }
325
+ if len(matchScoped) > 0 {
326
+ matches = matchScoped
327
+ }
328
+ }
329
+
330
+ if preferredLeagueID := strings.TrimSpace(context.PreferredLeagueID); preferredLeagueID != "" {
331
+ leagueScoped := make([]scored, 0, len(matches))
332
+ for _, candidate := range matches {
333
+ if strings.TrimSpace(candidate.entity.LeagueID) == preferredLeagueID {
334
+ leagueScoped = append(leagueScoped, candidate)
335
+ }
336
+ }
337
+ if len(leagueScoped) > 0 {
338
+ matches = leagueScoped
339
+ }
340
+ }
341
+
342
+ sort.Slice(matches, func(a, b int) bool {
343
+ if matches[a].score != matches[b].score {
344
+ return matches[a].score > matches[b].score
345
+ }
346
+ if !matches[a].entity.UpdatedAt.Equal(matches[b].entity.UpdatedAt) {
347
+ return matches[a].entity.UpdatedAt.After(matches[b].entity.UpdatedAt)
348
+ }
349
+ return matches[a].entity.ID < matches[b].entity.ID
350
+ })
351
+
352
+ if len(matches) > limit {
353
+ matches = matches[:limit]
354
+ }
355
+
356
+ out := make([]IndexedEntity, 0, len(matches))
357
+ for _, item := range matches {
358
+ out = append(out, item.entity)
359
+ }
360
+ return out
361
+ }
362
+
363
+ // LastEventsSeedAt returns the timestamp for the last /events hydration pass.
364
+ func (i *EntityIndex) LastEventsSeedAt() time.Time {
365
+ i.mu.RLock()
366
+ defer i.mu.RUnlock()
367
+ return i.lastEventsSeedAt
368
+ }
369
+
370
+ // SetLastEventsSeedAt updates the /events hydration marker.
371
+ func (i *EntityIndex) SetLastEventsSeedAt(ts time.Time) {
372
+ i.mu.Lock()
373
+ defer i.mu.Unlock()
374
+ if ts.IsZero() {
375
+ return
376
+ }
377
+ i.lastEventsSeedAt = ts.UTC()
378
+ i.dirty = true
379
+ }
380
+
381
+ // MarkHydratedRef records that a ref has already been traversed.
382
+ func (i *EntityIndex) MarkHydratedRef(ref string, when time.Time) {
383
+ ref = normalizeRef(ref)
384
+ if ref == "" {
385
+ return
386
+ }
387
+ if when.IsZero() {
388
+ when = time.Now().UTC()
389
+ }
390
+
391
+ i.mu.Lock()
392
+ defer i.mu.Unlock()
393
+ i.hydratedRefs[ref] = when.UTC()
394
+ i.dirty = true
395
+ }
396
+
397
+ // HydratedRefAt returns when a ref was hydrated, if known.
398
+ func (i *EntityIndex) HydratedRefAt(ref string) (time.Time, bool) {
399
+ i.mu.RLock()
400
+ defer i.mu.RUnlock()
401
+
402
+ ref = normalizeRef(ref)
403
+ if ref == "" {
404
+ return time.Time{}, false
405
+ }
406
+ ts, ok := i.hydratedRefs[ref]
407
+ return ts, ok
408
+ }
409
+
410
+ func mergeIndexedEntity(existing, incoming IndexedEntity) IndexedEntity {
411
+ merged := existing
412
+ if incoming.Ref != "" {
413
+ merged.Ref = incoming.Ref
414
+ }
415
+ if incoming.Name != "" {
416
+ merged.Name = incoming.Name
417
+ }
418
+ if incoming.ShortName != "" {
419
+ merged.ShortName = incoming.ShortName
420
+ }
421
+ if incoming.LeagueID != "" {
422
+ merged.LeagueID = incoming.LeagueID
423
+ }
424
+ if incoming.EventID != "" {
425
+ merged.EventID = incoming.EventID
426
+ }
427
+ if incoming.MatchID != "" {
428
+ merged.MatchID = incoming.MatchID
429
+ }
430
+ if !incoming.UpdatedAt.IsZero() && incoming.UpdatedAt.After(existing.UpdatedAt) {
431
+ merged.UpdatedAt = incoming.UpdatedAt
432
+ }
433
+ if merged.UpdatedAt.IsZero() {
434
+ merged.UpdatedAt = time.Now().UTC()
435
+ }
436
+ return merged
437
+ }
438
+
439
+ func generateDefaultAliases(entity IndexedEntity) []string {
440
+ aliases := []string{entity.ID, entity.Name, entity.ShortName}
441
+ if entity.Ref != "" {
442
+ ids := refIDs(entity.Ref)
443
+ switch entity.Kind {
444
+ case EntityPlayer:
445
+ aliases = append(aliases, ids["athleteId"])
446
+ case EntityTeam:
447
+ aliases = append(aliases, ids["teamId"], ids["competitorId"])
448
+ case EntityLeague:
449
+ aliases = append(aliases, ids["leagueId"])
450
+ case EntityMatch:
451
+ aliases = append(aliases, ids["competitionId"], ids["eventId"])
452
+ }
453
+ }
454
+ return aliases
455
+ }
456
+
457
+ func mergeAliasSlices(slices ...[]string) []string {
458
+ seen := map[string]struct{}{}
459
+ out := make([]string, 0)
460
+ for _, current := range slices {
461
+ for _, value := range current {
462
+ value = strings.TrimSpace(value)
463
+ if value == "" {
464
+ continue
465
+ }
466
+ normalized := normalizeAlias(value)
467
+ if normalized == "" {
468
+ continue
469
+ }
470
+ if _, ok := seen[normalized]; ok {
471
+ continue
472
+ }
473
+ seen[normalized] = struct{}{}
474
+ out = append(out, value)
475
+ }
476
+ }
477
+ sort.Strings(out)
478
+ return out
479
+ }
480
+
481
+ func aliasSet(aliases []string) map[string]struct{} {
482
+ set := map[string]struct{}{}
483
+ for _, alias := range aliases {
484
+ normalized := normalizeAlias(alias)
485
+ if normalized == "" {
486
+ continue
487
+ }
488
+ set[normalized] = struct{}{}
489
+ }
490
+ return set
491
+ }
492
+
493
+ func aliasMatchScore(alias, query string, queryTokens []string) int {
494
+ if alias == "" || query == "" {
495
+ return 0
496
+ }
497
+ if alias == query {
498
+ return 1000
499
+ }
500
+ if strings.HasPrefix(alias, query) {
501
+ return 800
502
+ }
503
+ if strings.Contains(alias, query) {
504
+ return 650
505
+ }
506
+ aliasTokens := strings.Fields(alias)
507
+ if len(aliasTokens) == 0 || len(queryTokens) == 0 {
508
+ return 0
509
+ }
510
+
511
+ matched := 0
512
+ for _, qToken := range queryTokens {
513
+ for _, aliasToken := range aliasTokens {
514
+ if aliasTokenMatchesQuery(aliasToken, qToken) {
515
+ matched++
516
+ break
517
+ }
518
+ }
519
+ }
520
+ if matched == 0 {
521
+ return 0
522
+ }
523
+ return 300 + (matched * 60)
524
+ }
525
+
526
+ func aliasTokenMatchesQuery(aliasToken, queryToken string) bool {
527
+ if aliasToken == "" || queryToken == "" {
528
+ return false
529
+ }
530
+ if aliasToken == queryToken {
531
+ return true
532
+ }
533
+ if strings.HasPrefix(aliasToken, queryToken) {
534
+ return true
535
+ }
536
+ // Avoid treating single-letter initials as a match for full-name tokens.
537
+ return len(aliasToken) >= 2 && strings.HasPrefix(queryToken, aliasToken)
538
+ }
539
+
540
+ func normalizeAlias(raw string) string {
541
+ raw = strings.ToLower(strings.TrimSpace(raw))
542
+ if raw == "" {
543
+ return ""
544
+ }
545
+
546
+ var builder strings.Builder
547
+ lastSpace := false
548
+ for _, r := range raw {
549
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
550
+ builder.WriteRune(r)
551
+ lastSpace = false
552
+ continue
553
+ }
554
+ if !lastSpace {
555
+ builder.WriteRune(' ')
556
+ lastSpace = true
557
+ }
558
+ }
559
+ return strings.Join(strings.Fields(builder.String()), " ")
560
+ }
561
+
562
+ func normalizeRef(ref string) string {
563
+ return strings.TrimSpace(ref)
564
+ }
565
+
566
+ func entityIndexKey(kind EntityKind, id string) string {
567
+ return strings.TrimSpace(string(kind)) + ":" + strings.TrimSpace(id)
568
+ }
569
+
570
+ func maxInt(a, b int) int {
571
+ if a > b {
572
+ return a
573
+ }
574
+ return b
575
+ }
576
+
577
+ // ToRenderable converts a cached entity into a normalized render contract type.
578
+ func (e IndexedEntity) ToRenderable() any {
579
+ switch e.Kind {
580
+ case EntityPlayer:
581
+ return Player{
582
+ Ref: e.Ref,
583
+ ID: e.ID,
584
+ DisplayName: e.Name,
585
+ ShortName: e.ShortName,
586
+ }
587
+ case EntityTeam:
588
+ return Team{
589
+ Ref: e.Ref,
590
+ ID: e.ID,
591
+ Name: e.Name,
592
+ ShortName: e.ShortName,
593
+ }
594
+ case EntityLeague:
595
+ return League{
596
+ Ref: e.Ref,
597
+ ID: e.ID,
598
+ Name: e.Name,
599
+ Slug: e.ShortName,
600
+ }
601
+ case EntityMatch:
602
+ return Match{
603
+ Ref: e.Ref,
604
+ ID: e.ID,
605
+ CompetitionID: e.ID,
606
+ EventID: e.EventID,
607
+ LeagueID: e.LeagueID,
608
+ Description: e.Name,
609
+ ShortDescription: e.ShortName,
610
+ }
611
+ default:
612
+ return map[string]any{
613
+ "id": e.ID,
614
+ "name": e.Name,
615
+ "ref": e.Ref,
616
+ }
617
+ }
618
+ }