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.
- package/AGENTS.md +63 -0
- package/CONTRIBUTORS.md +75 -0
- package/LICENSE +21 -0
- package/Makefile +131 -0
- package/README.md +130 -0
- package/bin/cricinfo.js +44 -0
- package/cmd/cricinfo/main.go +15 -0
- package/go.mod +10 -0
- package/go.sum +10 -0
- package/internal/app/app.go +11 -0
- package/internal/app/app_test.go +122 -0
- package/internal/buildinfo/buildinfo.go +16 -0
- package/internal/cli/analysis.go +262 -0
- package/internal/cli/analysis_test.go +175 -0
- package/internal/cli/competitions.go +154 -0
- package/internal/cli/competitions_test.go +165 -0
- package/internal/cli/leagues.go +297 -0
- package/internal/cli/leagues_test.go +194 -0
- package/internal/cli/matches.go +403 -0
- package/internal/cli/matches_test.go +413 -0
- package/internal/cli/players.go +263 -0
- package/internal/cli/players_test.go +384 -0
- package/internal/cli/root.go +141 -0
- package/internal/cli/search.go +119 -0
- package/internal/cli/teams.go +214 -0
- package/internal/cli/teams_test.go +192 -0
- package/internal/cricinfo/analysis.go +1401 -0
- package/internal/cricinfo/analysis_phase15_test.go +267 -0
- package/internal/cricinfo/client.go +471 -0
- package/internal/cricinfo/client_test.go +280 -0
- package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
- package/internal/cricinfo/competitions.go +405 -0
- package/internal/cricinfo/competitions_phase13_test.go +234 -0
- package/internal/cricinfo/coverage_ledger.go +122 -0
- package/internal/cricinfo/coverage_ledger_test.go +253 -0
- package/internal/cricinfo/decode.go +115 -0
- package/internal/cricinfo/decode_test.go +100 -0
- package/internal/cricinfo/entity_index.go +618 -0
- package/internal/cricinfo/entity_index_test.go +175 -0
- package/internal/cricinfo/fixture_matrix.go +243 -0
- package/internal/cricinfo/fixture_matrix_test.go +49 -0
- package/internal/cricinfo/fixtures_test.go +264 -0
- package/internal/cricinfo/historical_hydration.go +1641 -0
- package/internal/cricinfo/historical_phase14_test.go +542 -0
- package/internal/cricinfo/leagues.go +1210 -0
- package/internal/cricinfo/leagues_phase12_test.go +324 -0
- package/internal/cricinfo/live_leagues_test.go +169 -0
- package/internal/cricinfo/live_matches_test.go +203 -0
- package/internal/cricinfo/live_matrix_test.go +118 -0
- package/internal/cricinfo/live_players_test.go +122 -0
- package/internal/cricinfo/live_search_test.go +86 -0
- package/internal/cricinfo/live_smoke_test.go +213 -0
- package/internal/cricinfo/live_teams_test.go +104 -0
- package/internal/cricinfo/matches.go +1508 -0
- package/internal/cricinfo/matches_phase7_test.go +207 -0
- package/internal/cricinfo/matches_phase9_test.go +253 -0
- package/internal/cricinfo/normalize_entities.go +1727 -0
- package/internal/cricinfo/normalize_leagues.go +346 -0
- package/internal/cricinfo/players.go +1332 -0
- package/internal/cricinfo/players_phase10_test.go +174 -0
- package/internal/cricinfo/players_phase11_test.go +373 -0
- package/internal/cricinfo/render_contract.go +1088 -0
- package/internal/cricinfo/render_phase4_test.go +633 -0
- package/internal/cricinfo/renderer.go +1689 -0
- package/internal/cricinfo/resolver.go +813 -0
- package/internal/cricinfo/resolver_test.go +244 -0
- package/internal/cricinfo/teams.go +603 -0
- package/internal/cricinfo/teams_phase8_test.go +231 -0
- package/internal/cricinfo/testdata/fixtures/README.md +43 -0
- package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
- package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
- package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
- package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
- package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
- package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
- package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
- package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
- package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
- package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
- package/internal/cricinfo/types.go +54 -0
- package/package.json +51 -0
- 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
|
+
}
|