@pipeworx/mcp-cricsheet 0.1.0 → 0.2.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/README.md +23 -12
- package/bin/cli.js +55 -11
- package/lib/index.js +333 -0
- package/package.json +17 -16
- package/server.json +1 -1
- package/tools.json +182 -0
- package/src/index.ts +0 -423
- package/src/server.ts +0 -48
- package/src/store.ts +0 -372
- package/tsconfig.json +0 -18
package/src/store.ts
DELETED
|
@@ -1,372 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reads for cricsheet (fleet #2975).
|
|
3
|
-
*
|
|
4
|
-
* Data: Cricsheet (cricsheet.org) -- per-match ball-by-ball JSON files for
|
|
5
|
-
* Test, ODI and T20 International cricket plus the Indian Premier League
|
|
6
|
-
* (ODC Attribution License, ODC-BY). Cricsheet publishes bulk files only, so
|
|
7
|
-
* this pack builds a copy (scripts/cricsheet/load-data.mjs) into a D1
|
|
8
|
-
* database the gateway hands it as `_d1` (pack-manifest "d1Binding":
|
|
9
|
-
* "CRICSHEET_DB"). Each match file is parsed ball-by-ball IN THE LOADER and
|
|
10
|
-
* collapsed to match / innings / batting-card / bowling-card rows --
|
|
11
|
-
* individual deliveries are never stored, per CLAUDE.md "prefer the
|
|
12
|
-
* smallest store that answers the tool questions."
|
|
13
|
-
*
|
|
14
|
-
* Design note: this file + index.ts.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** The slice of Cloudflare's D1Database this pack uses, declared here so the
|
|
18
|
-
* pack needs no workers-types and a test can pass a node:sqlite stub. */
|
|
19
|
-
export interface D1Like {
|
|
20
|
-
prepare(sql: string): D1StmtLike;
|
|
21
|
-
}
|
|
22
|
-
export interface D1StmtLike {
|
|
23
|
-
bind(...values: unknown[]): D1StmtLike;
|
|
24
|
-
all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;
|
|
25
|
-
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export class StoreUnavailableError extends Error {
|
|
29
|
-
constructor() {
|
|
30
|
-
super(
|
|
31
|
-
'cricsheet: the match store is not bound in this runtime (no _d1). These tools only answer through the Pipeworx gateway; a standalone install has no data to read.',
|
|
32
|
-
);
|
|
33
|
-
this.name = 'StoreUnavailableError';
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
export function requireDb(d1: unknown): D1Like {
|
|
37
|
-
if (!d1 || typeof (d1 as D1Like).prepare !== 'function') throw new StoreUnavailableError();
|
|
38
|
-
return d1 as D1Like;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export const FORMATS = ['Test', 'ODI', 'T20I', 'IPL'] as const;
|
|
42
|
-
export type Format = (typeof FORMATS)[number];
|
|
43
|
-
|
|
44
|
-
export function resolveFormat(input: unknown): Format | null {
|
|
45
|
-
if (input == null || input === '') return null;
|
|
46
|
-
const s = String(input).trim().toUpperCase();
|
|
47
|
-
const map: Record<string, Format> = {
|
|
48
|
-
TEST: 'Test', TESTS: 'Test',
|
|
49
|
-
ODI: 'ODI', ODIS: 'ODI', 'ONE DAY INTERNATIONAL': 'ODI', 'ONE-DAY INTERNATIONAL': 'ODI',
|
|
50
|
-
T20I: 'T20I', T20IS: 'T20I', T20: 'T20I', 'T20 INTERNATIONAL': 'T20I', 'T20 INTERNATIONALS': 'T20I',
|
|
51
|
-
IPL: 'IPL', 'INDIAN PREMIER LEAGUE': 'IPL',
|
|
52
|
-
};
|
|
53
|
-
return map[s] ?? null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export async function dataAsOf(db: D1Like): Promise<string | null> {
|
|
57
|
-
const r = await db.prepare(`SELECT value FROM cricsheet_meta WHERE key = 'loaded_at'`).first<{ value: string }>();
|
|
58
|
-
return r?.value ?? null;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface MatchRow {
|
|
62
|
-
match_id: number;
|
|
63
|
-
format: string;
|
|
64
|
-
gender: string;
|
|
65
|
-
team1: string;
|
|
66
|
-
team2: string;
|
|
67
|
-
match_date: string;
|
|
68
|
-
season: string | null;
|
|
69
|
-
venue: string | null;
|
|
70
|
-
city: string | null;
|
|
71
|
-
event_name: string | null;
|
|
72
|
-
toss_winner: string | null;
|
|
73
|
-
toss_decision: string | null;
|
|
74
|
-
outcome_winner: string | null;
|
|
75
|
-
outcome_by_runs: number | null;
|
|
76
|
-
outcome_by_wickets: number | null;
|
|
77
|
-
outcome_by_innings: number | null;
|
|
78
|
-
outcome_method: string | null;
|
|
79
|
-
result: string | null;
|
|
80
|
-
player_of_match: string | null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export async function getMatch(db: D1Like, matchId: number): Promise<MatchRow | null> {
|
|
84
|
-
return db
|
|
85
|
-
.prepare(
|
|
86
|
-
`SELECT match_id, format, gender, team1, team2, match_date, season, venue, city, event_name,
|
|
87
|
-
toss_winner, toss_decision, outcome_winner, outcome_by_runs, outcome_by_wickets,
|
|
88
|
-
outcome_by_innings, outcome_method, result, player_of_match
|
|
89
|
-
FROM matches WHERE match_id = ?`,
|
|
90
|
-
)
|
|
91
|
-
.bind(matchId)
|
|
92
|
-
.first<MatchRow>();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface InningsRow {
|
|
96
|
-
innings_num: number;
|
|
97
|
-
batting_team: string;
|
|
98
|
-
total_runs: number;
|
|
99
|
-
total_wickets: number;
|
|
100
|
-
legal_balls: number;
|
|
101
|
-
}
|
|
102
|
-
export async function getInnings(db: D1Like, matchId: number): Promise<InningsRow[]> {
|
|
103
|
-
const r = await db
|
|
104
|
-
.prepare(`SELECT innings_num, batting_team, total_runs, total_wickets, legal_balls FROM innings WHERE match_id = ? ORDER BY innings_num`)
|
|
105
|
-
.bind(matchId)
|
|
106
|
-
.all<InningsRow>();
|
|
107
|
-
return r.results;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export interface BattingCardRow {
|
|
111
|
-
innings_num: number;
|
|
112
|
-
position: number;
|
|
113
|
-
player: string;
|
|
114
|
-
runs: number;
|
|
115
|
-
balls: number;
|
|
116
|
-
fours: number;
|
|
117
|
-
sixes: number;
|
|
118
|
-
not_out: number;
|
|
119
|
-
dismissal_kind: string | null;
|
|
120
|
-
dismissal_bowler: string | null;
|
|
121
|
-
}
|
|
122
|
-
export async function getBattingCards(db: D1Like, matchId: number): Promise<BattingCardRow[]> {
|
|
123
|
-
const r = await db
|
|
124
|
-
.prepare(
|
|
125
|
-
`SELECT innings_num, position, player, runs, balls, fours, sixes, not_out, dismissal_kind, dismissal_bowler
|
|
126
|
-
FROM batting_cards WHERE match_id = ? ORDER BY innings_num, position`,
|
|
127
|
-
)
|
|
128
|
-
.bind(matchId)
|
|
129
|
-
.all<BattingCardRow>();
|
|
130
|
-
return r.results;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export interface BowlingCardRow {
|
|
134
|
-
innings_num: number;
|
|
135
|
-
order_num: number;
|
|
136
|
-
player: string;
|
|
137
|
-
balls: number;
|
|
138
|
-
runs_conceded: number;
|
|
139
|
-
wickets: number;
|
|
140
|
-
}
|
|
141
|
-
export async function getBowlingCards(db: D1Like, matchId: number): Promise<BowlingCardRow[]> {
|
|
142
|
-
const r = await db
|
|
143
|
-
.prepare(
|
|
144
|
-
`SELECT innings_num, order_num, player, balls, runs_conceded, wickets
|
|
145
|
-
FROM bowling_cards WHERE match_id = ? ORDER BY innings_num, order_num`,
|
|
146
|
-
)
|
|
147
|
-
.bind(matchId)
|
|
148
|
-
.all<BowlingCardRow>();
|
|
149
|
-
return r.results;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Resolve a free-text player query against the small `players` table
|
|
153
|
-
* (one row per distinct name string Cricsheet uses). Cricsheet names are
|
|
154
|
-
* short forms ("V Kohli", "SR Watson") -- matching is case-insensitive
|
|
155
|
-
* substring, ranked by how many matches that name appears in, so a common
|
|
156
|
-
* surname like "Kohli" resolves unambiguously while a genuinely ambiguous
|
|
157
|
-
* query (e.g. a shared surname) comes back as a candidate list rather than
|
|
158
|
-
* picking one silently. */
|
|
159
|
-
export interface PlayerCandidate {
|
|
160
|
-
player: string;
|
|
161
|
-
matches: number;
|
|
162
|
-
}
|
|
163
|
-
export async function resolvePlayer(db: D1Like, query: string): Promise<{ resolved: string | null; candidates: PlayerCandidate[] }> {
|
|
164
|
-
const exact = await db
|
|
165
|
-
.prepare(`SELECT player, matches FROM players WHERE player = ? COLLATE NOCASE`)
|
|
166
|
-
.bind(query)
|
|
167
|
-
.first<PlayerCandidate>();
|
|
168
|
-
if (exact) return { resolved: exact.player, candidates: [exact] };
|
|
169
|
-
|
|
170
|
-
const r = await db
|
|
171
|
-
.prepare(`SELECT player, matches FROM players WHERE player LIKE ? COLLATE NOCASE ORDER BY matches DESC LIMIT 20`)
|
|
172
|
-
.bind(`%${query}%`)
|
|
173
|
-
.all<PlayerCandidate>();
|
|
174
|
-
if (r.results.length === 1) return { resolved: r.results[0].player, candidates: r.results };
|
|
175
|
-
return { resolved: null, candidates: r.results };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export interface BattingAgg {
|
|
179
|
-
innings: number;
|
|
180
|
-
runs: number;
|
|
181
|
-
balls: number;
|
|
182
|
-
outs: number;
|
|
183
|
-
not_outs: number;
|
|
184
|
-
fours: number;
|
|
185
|
-
sixes: number;
|
|
186
|
-
high_score: number | null;
|
|
187
|
-
hundreds: number;
|
|
188
|
-
fifties: number;
|
|
189
|
-
average: number | null;
|
|
190
|
-
strike_rate: number | null;
|
|
191
|
-
}
|
|
192
|
-
export interface BowlingAgg {
|
|
193
|
-
innings: number;
|
|
194
|
-
balls: number;
|
|
195
|
-
runs_conceded: number;
|
|
196
|
-
wickets: number;
|
|
197
|
-
best_wickets: number | null;
|
|
198
|
-
average: number | null;
|
|
199
|
-
economy: number | null;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export async function battingAggregate(db: D1Like, player: string, format: Format | null): Promise<BattingAgg> {
|
|
203
|
-
const where = format ? 'player = ? AND format = ?' : 'player = ?';
|
|
204
|
-
const params = format ? [player, format] : [player];
|
|
205
|
-
const row = await db
|
|
206
|
-
.prepare(
|
|
207
|
-
`SELECT COUNT(*) AS innings, COALESCE(SUM(runs),0) AS runs, COALESCE(SUM(balls),0) AS balls,
|
|
208
|
-
COALESCE(SUM(CASE WHEN not_out = 0 THEN 1 ELSE 0 END),0) AS outs,
|
|
209
|
-
COALESCE(SUM(CASE WHEN not_out = 1 THEN 1 ELSE 0 END),0) AS not_outs,
|
|
210
|
-
COALESCE(SUM(fours),0) AS fours, COALESCE(SUM(sixes),0) AS sixes,
|
|
211
|
-
MAX(runs) AS high_score,
|
|
212
|
-
COALESCE(SUM(CASE WHEN runs >= 100 THEN 1 ELSE 0 END),0) AS hundreds,
|
|
213
|
-
COALESCE(SUM(CASE WHEN runs >= 50 AND runs < 100 THEN 1 ELSE 0 END),0) AS fifties
|
|
214
|
-
FROM batting_cards WHERE ${where}`,
|
|
215
|
-
)
|
|
216
|
-
.bind(...params)
|
|
217
|
-
.first<{ innings: number; runs: number; balls: number; outs: number; not_outs: number; fours: number; sixes: number; high_score: number | null; hundreds: number; fifties: number }>();
|
|
218
|
-
const innings = row?.innings ?? 0;
|
|
219
|
-
const runs = row?.runs ?? 0;
|
|
220
|
-
const balls = row?.balls ?? 0;
|
|
221
|
-
const outs = row?.outs ?? 0;
|
|
222
|
-
return {
|
|
223
|
-
innings,
|
|
224
|
-
runs,
|
|
225
|
-
balls,
|
|
226
|
-
outs,
|
|
227
|
-
not_outs: row?.not_outs ?? 0,
|
|
228
|
-
fours: row?.fours ?? 0,
|
|
229
|
-
sixes: row?.sixes ?? 0,
|
|
230
|
-
high_score: row?.high_score ?? null,
|
|
231
|
-
hundreds: row?.hundreds ?? 0,
|
|
232
|
-
fifties: row?.fifties ?? 0,
|
|
233
|
-
average: outs > 0 ? round2(runs / outs) : null,
|
|
234
|
-
strike_rate: balls > 0 ? round2((runs / balls) * 100) : null,
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export async function bowlingAggregate(db: D1Like, player: string, format: Format | null): Promise<BowlingAgg> {
|
|
239
|
-
const where = format ? 'player = ? AND format = ?' : 'player = ?';
|
|
240
|
-
const params = format ? [player, format] : [player];
|
|
241
|
-
const row = await db
|
|
242
|
-
.prepare(
|
|
243
|
-
`SELECT COUNT(*) AS innings, COALESCE(SUM(balls),0) AS balls, COALESCE(SUM(runs_conceded),0) AS runs_conceded,
|
|
244
|
-
COALESCE(SUM(wickets),0) AS wickets, MAX(wickets) AS best_wickets
|
|
245
|
-
FROM bowling_cards WHERE ${where}`,
|
|
246
|
-
)
|
|
247
|
-
.bind(...params)
|
|
248
|
-
.first<{ innings: number; balls: number; runs_conceded: number; wickets: number; best_wickets: number | null }>();
|
|
249
|
-
const innings = row?.innings ?? 0;
|
|
250
|
-
const balls = row?.balls ?? 0;
|
|
251
|
-
const runs_conceded = row?.runs_conceded ?? 0;
|
|
252
|
-
const wickets = row?.wickets ?? 0;
|
|
253
|
-
return {
|
|
254
|
-
innings,
|
|
255
|
-
balls,
|
|
256
|
-
runs_conceded,
|
|
257
|
-
wickets,
|
|
258
|
-
best_wickets: row?.best_wickets ?? null,
|
|
259
|
-
average: wickets > 0 ? round2(runs_conceded / wickets) : null,
|
|
260
|
-
economy: balls > 0 ? round2(runs_conceded / (balls / 6)) : null,
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function round2(n: number): number {
|
|
265
|
-
return Math.round(n * 100) / 100;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
export interface HeadToHeadFilter {
|
|
269
|
-
team_a: string;
|
|
270
|
-
team_b: string;
|
|
271
|
-
format: Format | null;
|
|
272
|
-
limit: number;
|
|
273
|
-
offset: number;
|
|
274
|
-
}
|
|
275
|
-
export async function headToHeadMatches(db: D1Like, f: HeadToHeadFilter): Promise<MatchRow[]> {
|
|
276
|
-
const where = ['((team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE) OR (team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE))'];
|
|
277
|
-
const params: unknown[] = [f.team_a, f.team_b, f.team_b, f.team_a];
|
|
278
|
-
if (f.format) {
|
|
279
|
-
where.push('format = ?');
|
|
280
|
-
params.push(f.format);
|
|
281
|
-
}
|
|
282
|
-
const sql = `SELECT match_id, format, gender, team1, team2, match_date, season, venue, city, event_name,
|
|
283
|
-
toss_winner, toss_decision, outcome_winner, outcome_by_runs, outcome_by_wickets,
|
|
284
|
-
outcome_by_innings, outcome_method, result, player_of_match
|
|
285
|
-
FROM matches WHERE ${where.join(' AND ')} ORDER BY match_date DESC LIMIT ? OFFSET ?`;
|
|
286
|
-
params.push(f.limit, f.offset);
|
|
287
|
-
const r = await db.prepare(sql).bind(...params).all<MatchRow>();
|
|
288
|
-
return r.results;
|
|
289
|
-
}
|
|
290
|
-
export async function headToHeadCount(db: D1Like, f: Omit<HeadToHeadFilter, 'limit' | 'offset'>): Promise<number> {
|
|
291
|
-
const where = ['((team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE) OR (team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE))'];
|
|
292
|
-
const params: unknown[] = [f.team_a, f.team_b, f.team_b, f.team_a];
|
|
293
|
-
if (f.format) {
|
|
294
|
-
where.push('format = ?');
|
|
295
|
-
params.push(f.format);
|
|
296
|
-
}
|
|
297
|
-
const r = await db.prepare(`SELECT COUNT(*) AS n FROM matches WHERE ${where.join(' AND ')}`).bind(...params).first<{ n: number }>();
|
|
298
|
-
return r?.n ?? 0;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export interface FindMatchesFilter {
|
|
302
|
-
team?: string;
|
|
303
|
-
format?: Format | null;
|
|
304
|
-
season?: string;
|
|
305
|
-
venue?: string;
|
|
306
|
-
player?: string;
|
|
307
|
-
limit: number;
|
|
308
|
-
offset: number;
|
|
309
|
-
}
|
|
310
|
-
export async function findMatches(db: D1Like, f: FindMatchesFilter): Promise<MatchRow[]> {
|
|
311
|
-
const where: string[] = [];
|
|
312
|
-
const params: unknown[] = [];
|
|
313
|
-
if (f.team) {
|
|
314
|
-
where.push('(team1 = ? COLLATE NOCASE OR team2 = ? COLLATE NOCASE)');
|
|
315
|
-
params.push(f.team, f.team);
|
|
316
|
-
}
|
|
317
|
-
if (f.format) {
|
|
318
|
-
where.push('format = ?');
|
|
319
|
-
params.push(f.format);
|
|
320
|
-
}
|
|
321
|
-
if (f.season) {
|
|
322
|
-
where.push('season = ? COLLATE NOCASE');
|
|
323
|
-
params.push(f.season);
|
|
324
|
-
}
|
|
325
|
-
if (f.venue) {
|
|
326
|
-
where.push('venue LIKE ? COLLATE NOCASE');
|
|
327
|
-
params.push(`%${f.venue}%`);
|
|
328
|
-
}
|
|
329
|
-
if (f.player) {
|
|
330
|
-
where.push(
|
|
331
|
-
'match_id IN (SELECT match_id FROM batting_cards WHERE player = ? COLLATE NOCASE UNION SELECT match_id FROM bowling_cards WHERE player = ? COLLATE NOCASE)',
|
|
332
|
-
);
|
|
333
|
-
params.push(f.player, f.player);
|
|
334
|
-
}
|
|
335
|
-
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
336
|
-
const sql = `SELECT match_id, format, gender, team1, team2, match_date, season, venue, city, event_name,
|
|
337
|
-
toss_winner, toss_decision, outcome_winner, outcome_by_runs, outcome_by_wickets,
|
|
338
|
-
outcome_by_innings, outcome_method, result, player_of_match
|
|
339
|
-
FROM matches ${whereSql} ORDER BY match_date DESC LIMIT ? OFFSET ?`;
|
|
340
|
-
params.push(f.limit, f.offset);
|
|
341
|
-
const r = await db.prepare(sql).bind(...params).all<MatchRow>();
|
|
342
|
-
return r.results;
|
|
343
|
-
}
|
|
344
|
-
export async function findMatchesCount(db: D1Like, f: Omit<FindMatchesFilter, 'limit' | 'offset'>): Promise<number> {
|
|
345
|
-
const where: string[] = [];
|
|
346
|
-
const params: unknown[] = [];
|
|
347
|
-
if (f.team) {
|
|
348
|
-
where.push('(team1 = ? COLLATE NOCASE OR team2 = ? COLLATE NOCASE)');
|
|
349
|
-
params.push(f.team, f.team);
|
|
350
|
-
}
|
|
351
|
-
if (f.format) {
|
|
352
|
-
where.push('format = ?');
|
|
353
|
-
params.push(f.format);
|
|
354
|
-
}
|
|
355
|
-
if (f.season) {
|
|
356
|
-
where.push('season = ? COLLATE NOCASE');
|
|
357
|
-
params.push(f.season);
|
|
358
|
-
}
|
|
359
|
-
if (f.venue) {
|
|
360
|
-
where.push('venue LIKE ? COLLATE NOCASE');
|
|
361
|
-
params.push(`%${f.venue}%`);
|
|
362
|
-
}
|
|
363
|
-
if (f.player) {
|
|
364
|
-
where.push(
|
|
365
|
-
'match_id IN (SELECT match_id FROM batting_cards WHERE player = ? COLLATE NOCASE UNION SELECT match_id FROM bowling_cards WHERE player = ? COLLATE NOCASE)',
|
|
366
|
-
);
|
|
367
|
-
params.push(f.player, f.player);
|
|
368
|
-
}
|
|
369
|
-
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
370
|
-
const r = await db.prepare(`SELECT COUNT(*) AS n FROM matches ${whereSql}`).bind(...params).first<{ n: number }>();
|
|
371
|
-
return r?.n ?? 0;
|
|
372
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"lib": ["ES2022"],
|
|
7
|
-
"types": ["@cloudflare/workers-types"],
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"resolveJsonModule": true,
|
|
12
|
-
"outDir": "dist",
|
|
13
|
-
"rootDir": "src",
|
|
14
|
-
"declaration": true
|
|
15
|
-
},
|
|
16
|
-
"include": ["src"],
|
|
17
|
-
"exclude": ["src/server.ts"]
|
|
18
|
-
}
|