Implement Phase 2: API client, sync pipeline, and CSV card importers
Port the full data ingestion layer from Python to Rust: - Typed API client with 10 endpoints, serde response types, and error handling - Team/player/transaction sync with proper upsert semantics (preserves hand field) - Batter and pitcher CSV importers with 40+ column mappings each - Parse helpers for float/int/endurance with 21 unit tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3c0c206aba
commit
3c70ecc71a
291
rust/PHASE2_PROJECT_PLAN.json
Normal file
291
rust/PHASE2_PROJECT_PLAN.json
Normal file
@ -0,0 +1,291 @@
|
||||
{
|
||||
"meta": {
|
||||
"version": "1.0.0",
|
||||
"created": "2026-02-27",
|
||||
"lastUpdated": "2026-02-27",
|
||||
"planType": "migration",
|
||||
"phase": "Phase 2: API Client + Sync + CSV Import",
|
||||
"description": "Port the data ingestion pipeline — HTTP API client, sync functions, and CSV card importer — from Python to Rust. This phase populates the database that Phase 1 created.",
|
||||
"totalEstimatedHours": 16,
|
||||
"totalTasks": 11,
|
||||
"completedTasks": 0
|
||||
},
|
||||
"categories": {
|
||||
"critical": "Must complete before sync or import can work",
|
||||
"high": "Required for data pipeline to function end-to-end",
|
||||
"medium": "Import functions needed to populate card data",
|
||||
"low": "Orchestration and polish"
|
||||
},
|
||||
"tasks": [
|
||||
{
|
||||
"id": "CRIT-001",
|
||||
"name": "Define API response types (serde structs)",
|
||||
"description": "Create typed response structs for all API endpoints. The league API returns JSON with nested objects and field names that differ from DB column names (e.g. 'sname' → 'short_name', 'wara' → 'swar'). Use serde rename attributes to handle the mismatches at deserialization time.",
|
||||
"category": "critical",
|
||||
"priority": 1,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "rust/src/api/client.rs",
|
||||
"lines": [1],
|
||||
"issue": "No response types defined"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "Create a new file `rust/src/api/types.rs` and add to `api/mod.rs`. Define structs:\n\n1. `TeamsResponse { count: u32, teams: Vec<TeamData> }`\n2. `TeamData` — flat fields plus nested `manager1: Option<Manager>`, `manager2: Option<Manager>`, `division: Option<Division>`. Use `#[serde(rename = \"sname\")]` for `short_name`, `#[serde(rename = \"lname\")]` for `long_name`, `#[serde(rename = \"gmid\")]` for `gm_discord_id`, `#[serde(rename = \"gmid2\")]` for `gm2_discord_id`. Manager struct: `{ name: Option<String> }`. Division struct: `{ id: Option<i64>, division_name: Option<String>, league_abbrev: Option<String> }`.\n3. `PlayersResponse { count: u32, players: Vec<PlayerData> }`\n4. `PlayerData` — use `#[serde(rename = \"wara\")]` for `swar`, `#[serde(rename = \"sbaplayer\")]` for `sbaplayer_id`, `#[serde(rename = \"image\")]` for `card_image`, `#[serde(rename = \"image2\")]` for `card_image_alt`. Nested `team: Option<TeamRef>` where `TeamRef { id: i64 }`.\n5. `TransactionsResponse { count: u32, transactions: Vec<TransactionData> }`\n6. `TransactionData` — `#[serde(rename = \"moveid\")]` for `move_id`. Nested `player: Option<PlayerRef>`, `oldteam: Option<TeamRef>`, `newteam: Option<TeamRef>`.\n7. `CurrentResponse { season: i64, week: i64 }` (for get_current endpoint).\n\nAll structs derive `Debug, Deserialize`. Use `Option<>` liberally — API responses have many nullable fields. Use `#[serde(default)]` on optional fields to handle missing keys gracefully.",
|
||||
"estimatedHours": 1.5,
|
||||
"notes": "Key gotchas: gmid/gmid2 come as integers from the API but are stored as String in DB — deserialize as Option<i64> then .map(|id| id.to_string()). The 'team' field on PlayerData is an object {id: N} not a bare integer."
|
||||
},
|
||||
{
|
||||
"id": "CRIT-002",
|
||||
"name": "Define API error type",
|
||||
"description": "Create a proper error enum for API operations using thiserror. The Python client has special handling for Cloudflare 403 errors and distinguishes HTTP errors from network errors.",
|
||||
"category": "critical",
|
||||
"priority": 2,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [
|
||||
{
|
||||
"path": "rust/src/api/client.rs",
|
||||
"lines": [1],
|
||||
"issue": "Uses anyhow::Result with no typed errors"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "Add to `api/client.rs` or a new `api/error.rs`:\n\n```rust\n#[derive(Debug, thiserror::Error)]\npub enum ApiError {\n #[error(\"HTTP {status}: {message}\")]\n Http { status: u16, message: String },\n\n #[error(\"Cloudflare blocked the request (403). The API may be down or rate-limiting.\")]\n CloudflareBlocked,\n\n #[error(\"Request failed: {0}\")]\n Request(#[from] reqwest::Error),\n\n #[error(\"JSON parse error: {0}\")]\n Parse(#[from] serde_json::Error),\n}\n```\n\nIn the request method: if status == 403 and body contains 'cloudflare' (case-insensitive), return CloudflareBlocked. Otherwise return Http variant.",
|
||||
"estimatedHours": 0.5,
|
||||
"notes": "Keep it simple. The Python error has status_code as an optional field — in Rust we can use separate variants instead."
|
||||
},
|
||||
{
|
||||
"id": "CRIT-003",
|
||||
"name": "Implement core _request method and auth",
|
||||
"description": "Add the internal request method to LeagueApiClient that handles authentication (Bearer token), base URL prefixing (/api/v3), response status checking, Cloudflare detection, and JSON deserialization. All public endpoint methods will delegate to this.",
|
||||
"category": "critical",
|
||||
"priority": 3,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["CRIT-001", "CRIT-002"],
|
||||
"files": [
|
||||
{
|
||||
"path": "rust/src/api/client.rs",
|
||||
"lines": [11, 23],
|
||||
"issue": "Only has new() constructor, no request logic"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "Add a private async method:\n\n```rust\nasync fn get<T: DeserializeOwned>(&self, path: &str, params: &[(& str, String)]) -> Result<T, ApiError>\n```\n\n1. Build URL: `format!(\"{}/api/v3{}\", self.base_url, path)`\n2. Create request with `self.client.get(url).query(params)`\n3. If `self.api_key` is not empty, add `.bearer_auth(&self.api_key)`\n4. Send and get response\n5. If `!response.status().is_success()`: read body text, check for cloudflare 403, else return Http error\n6. Deserialize JSON body to T\n\nNote: reqwest's .query() handles repeated params correctly when given a slice of tuples — e.g., `&[(\"team_id\", \"1\"), (\"team_id\", \"2\")]` produces `?team_id=1&team_id=2`.",
|
||||
"estimatedHours": 1,
|
||||
"notes": "The Python client uses httpx.AsyncClient as a context manager. In Rust, reqwest::Client is Clone + Send and can be reused freely — no context manager needed. Consider making api_key an Option<String> to properly represent 'no auth' vs 'empty string'."
|
||||
},
|
||||
{
|
||||
"id": "HIGH-001",
|
||||
"name": "Implement all API endpoint methods",
|
||||
"description": "Port all 10 public API methods from the Python client. Each method builds the correct path and query params, then delegates to the core _request/get method.",
|
||||
"category": "high",
|
||||
"priority": 4,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["CRIT-003"],
|
||||
"files": [
|
||||
{
|
||||
"path": "rust/src/api/client.rs",
|
||||
"lines": [11, 23],
|
||||
"issue": "No endpoint methods"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "Implement these methods on LeagueApiClient:\n\n1. `get_teams(season, team_abbrev, active_only, short_output)` → GET /teams\n2. `get_team(team_id)` → GET /teams/{id}\n3. `get_team_roster(team_id, which)` → GET /teams/{id}/roster/{which}\n4. `get_players(season, team_id, pos, name, short_output)` → GET /players\n5. `get_player(player_id, short_output)` → GET /players/{id}\n6. `search_players(query, season, limit)` → GET /players/search\n7. `get_transactions(season, week_start, week_end, team_abbrev, cancelled, frozen, short_output)` → GET /transactions\n8. `get_current()` → GET /current\n9. `get_schedule(season, week, team_id)` → GET /schedules\n10. `get_standings(season)` → GET /standings\n\nFor optional params, use `Option<T>` in the method signature and only add the param to the query slice when Some. For array params (team_id, team_abbrev, pos), accept `Option<&[T]>` and emit one tuple per element.\n\nReturn the typed response structs from CRIT-001.",
|
||||
"estimatedHours": 2.5,
|
||||
"notes": "get_players and get_transactions have the most params. Consider a builder pattern or param struct if the signatures get unwieldy, but plain method args are fine for now — matches the Python style."
|
||||
},
|
||||
{
|
||||
"id": "HIGH-002",
|
||||
"name": "Implement sync_teams",
|
||||
"description": "Port the team sync function. Fetches teams from API and upserts into the database. Must handle the JSON field name mismatches (sname→short_name, lname→long_name, gmid→gm_discord_id, nested manager/division objects).",
|
||||
"category": "high",
|
||||
"priority": 5,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "rust/src/api/mod.rs",
|
||||
"lines": [1],
|
||||
"issue": "No sync module"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "Create `rust/src/api/sync.rs` and add `pub mod sync;` to api/mod.rs.\n\nImplement `pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient) -> Result<i64>`:\n\n1. Call `client.get_teams(Some(season), None, true, false)`\n2. Iterate response.teams\n3. For each TeamData: use `INSERT OR REPLACE INTO teams (id, abbrev, short_name, long_name, season, ...) VALUES (?, ?, ?, ...)`. Map: `data.short_name` (already renamed by serde), flatten `data.manager1.map(|m| m.name)`, `data.division.map(|d| d.id)`, `data.gm_discord_id.map(|id| id.to_string())`, set `synced_at = Utc::now().naive_utc()`.\n4. Call `update_sync_status(pool, \"teams\", count, None)` from db::queries.\n5. Return count.\n\nUse a transaction (pool.begin()) to batch all inserts — faster and atomic.",
|
||||
"estimatedHours": 1.5,
|
||||
"notes": "The Python code does individual get-then-update/insert. The Rust version should use INSERT OR REPLACE for simplicity (matching Phase 1 upsert pattern). The season field is set on insert only in Python — in Rust, just always set it since we're using REPLACE."
|
||||
},
|
||||
{
|
||||
"id": "HIGH-003",
|
||||
"name": "Implement sync_players",
|
||||
"description": "Port the player sync function. Similar to team sync but with different field mappings (wara→swar, sbaplayer→sbaplayer_id, nested team.id).",
|
||||
"category": "high",
|
||||
"priority": 6,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "rust/src/api/sync.rs",
|
||||
"lines": [],
|
||||
"issue": "File doesn't exist yet"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "Implement `pub async fn sync_players(pool: &SqlitePool, season: i64, team_id: Option<i64>, client: &LeagueApiClient) -> Result<i64>`:\n\n1. Call `client.get_players(Some(season), team_id.map(|id| vec![id]).as_deref(), None, None, false)`\n2. Iterate response.players\n3. INSERT OR REPLACE with field mapping: `data.swar` (renamed by serde from wara), `data.team.map(|t| t.id)` for team_id, `data.sbaplayer_id` (renamed from sbaplayer), pos_1 through pos_8, etc.\n4. Important: do NOT overwrite `hand` field — it comes from CSV import only. Use `INSERT OR REPLACE` but if you need to preserve hand, use `INSERT ... ON CONFLICT(id) DO UPDATE SET name=excluded.name, ... ` and omit `hand` from the SET clause.\n5. Call `update_sync_status(pool, \"players\", count, None)`.\n6. Return count.\n\nUse transaction for batching.",
|
||||
"estimatedHours": 1.5,
|
||||
"notes": "Critical: player.hand must NOT be overwritten by API sync (it's CSV-only). Use ON CONFLICT DO UPDATE with explicit column list instead of INSERT OR REPLACE, which would null out hand. This is different from the team sync pattern."
|
||||
},
|
||||
{
|
||||
"id": "HIGH-004",
|
||||
"name": "Implement sync_transactions",
|
||||
"description": "Port the transaction sync function. Transactions use a composite key (move_id, player_id) for dedup, not a simple PK. Must skip rows with missing moveid or player.id.",
|
||||
"category": "high",
|
||||
"priority": 7,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "rust/src/api/sync.rs",
|
||||
"lines": [],
|
||||
"issue": "File doesn't exist yet"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "Implement `pub async fn sync_transactions(pool: &SqlitePool, season: i64, week_start: i64, week_end: Option<i64>, team_abbrev: Option<&str>, client: &LeagueApiClient) -> Result<i64>`:\n\n1. Call `client.get_transactions(season, week_start, week_end, team_abbrev, false, false, false)`\n2. Iterate response.transactions\n3. Skip if moveid is None or player is None/player.id is None\n4. Use `INSERT INTO transactions (...) VALUES (...) ON CONFLICT(move_id, player_id) DO UPDATE SET cancelled=excluded.cancelled, frozen=excluded.frozen, synced_at=excluded.synced_at` — only update the mutable fields on conflict.\n5. Map: `data.move_id` (renamed from moveid), `data.player.unwrap().id`, `data.oldteam.map(|t| t.id)`, `data.newteam.map(|t| t.id)`, set season and week.\n6. Call `update_sync_status(pool, \"transactions\", count, None)`.\n7. Return count.\n\nUse transaction for batching.",
|
||||
"estimatedHours": 1,
|
||||
"notes": "The transactions table has UNIQUE(move_id, player_id). ON CONFLICT on that unique constraint is the right approach. Python does a SELECT-then-INSERT/UPDATE which is racy."
|
||||
},
|
||||
{
|
||||
"id": "HIGH-005",
|
||||
"name": "Implement sync_all orchestrator",
|
||||
"description": "Port sync_all which creates one API client and runs all three sync functions sequentially, returning a summary of counts.",
|
||||
"category": "high",
|
||||
"priority": 8,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["HIGH-002", "HIGH-003", "HIGH-004"],
|
||||
"files": [
|
||||
{
|
||||
"path": "rust/src/api/sync.rs",
|
||||
"lines": [],
|
||||
"issue": "File doesn't exist yet"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "Implement `pub async fn sync_all(pool: &SqlitePool, settings: &Settings) -> Result<SyncResult>`:\n\n1. Create `LeagueApiClient::new(&settings.api.base_url, &settings.api.api_key, settings.api.timeout)?`\n2. Call sync_teams, sync_players, sync_transactions sequentially\n3. For transactions, use week_start=1, week_end=None (sync all weeks)\n4. Return a SyncResult struct: `{ teams: i64, players: i64, transactions: i64 }`\n\nDefine `SyncResult` in sync.rs or types.rs.",
|
||||
"estimatedHours": 0.5,
|
||||
"notes": "Keep it simple. The Python version passes the client to each sync function — do the same. Consider logging each step's count."
|
||||
},
|
||||
{
|
||||
"id": "MED-001",
|
||||
"name": "Implement CSV helper functions (parse_float, parse_int, parse_endurance)",
|
||||
"description": "Port the three CSV parsing helpers. parse_int must handle '5.0' → 5 (parse as float then truncate). parse_endurance uses regex to extract S(n), R(n), C(n) from endurance strings like 'S(5) R(4)' or 'R(1) C(6)*'.",
|
||||
"category": "medium",
|
||||
"priority": 9,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": [],
|
||||
"files": [],
|
||||
"suggestedFix": "Create `rust/src/api/importer.rs` and add `pub mod importer;` to api/mod.rs.\n\nImplement:\n\n1. `fn parse_float(value: &str, default: f64) -> f64` — trim, return default if empty, parse f64 or return default.\n\n2. `fn parse_int(value: &str, default: i32) -> i32` — trim, return default if empty, parse as f64 first then truncate to i32. This handles '5.0' → 5.\n\n3. `fn parse_endurance(value: &str) -> (Option<i32>, Option<i32>, Option<i32>)` — use lazy_static or once_cell for compiled regexes:\n - `S\\((\\d+)\\*?\\)` → start\n - `R\\((\\d+)\\)` → relief\n - `C\\((\\d+)\\)` → close\n Return (start, relief, close) where any component may be None.\n\nWrite unit tests for all three, especially edge cases: empty string, whitespace, '5.0', 'S(5*) R(4)', 'C(6)', unparseable values.",
|
||||
"estimatedHours": 1,
|
||||
"notes": "Use `std::sync::LazyLock` (stable since Rust 1.80) for compiled regexes instead of lazy_static or once_cell. Keep these as module-level functions, not methods."
|
||||
},
|
||||
{
|
||||
"id": "MED-002",
|
||||
"name": "Implement import_batter_cards",
|
||||
"description": "Port the batter CSV importer. Reads BatterCalcs.csv, maps columns to BatterCard fields, upserts into database. Also updates player.hand from CSV. Returns (imported, skipped, errors) counts.",
|
||||
"category": "medium",
|
||||
"priority": 10,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["MED-001"],
|
||||
"files": [
|
||||
{
|
||||
"path": "rust/src/api/importer.rs",
|
||||
"lines": [],
|
||||
"issue": "File doesn't exist yet"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "Implement `pub async fn import_batter_cards(pool: &SqlitePool, csv_path: &Path, update_existing: bool) -> Result<ImportResult>`:\n\nDefine `ImportResult { imported: i64, skipped: i64, errors: Vec<String> }`.\n\n1. Open CSV with `csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)`\n2. Get headers, iterate records via `reader.records()`\n3. For each record:\n a. Parse player_id from column 'player_id' — skip if 0\n b. Look up player in DB — if not found, push error string and continue\n c. Update player.hand if column 'hand' is L/R/S: `UPDATE players SET hand = ? WHERE id = ?`\n d. If !update_existing, check if batter_card exists — if so, increment skipped and continue\n e. INSERT OR REPLACE batter card with all field mappings (see column map)\n f. Increment imported\n4. Catch per-row errors (wrap in match) → push to errors vec\n\nColumn mappings (CSV header → parse function → DB field):\n- 'SO vlhp' → parse_float → so_vlhp\n- 'BB v lhp' → parse_float → bb_vlhp\n- 'HIT v lhp' → parse_float → hit_vlhp\n- (etc. — 19 stat columns per side, plus stealing, STL, SPD, B, H, FIELDING, cArm/CA, vL, vR, Total)\n- Source set to csv filename\n\nUse a helper fn or macro to avoid repeating `get_column(headers, record, 'name')` 40+ times. Consider a HashMap<String, usize> for header→index lookup.",
|
||||
"estimatedHours": 2.5,
|
||||
"notes": "The catcher_arm column has two possible CSV header names: 'cArm' OR 'CA'. Check both. SPD defaults to 10 if missing. Use a transaction to batch all inserts."
|
||||
},
|
||||
{
|
||||
"id": "MED-003",
|
||||
"name": "Implement import_pitcher_cards and import_all_cards",
|
||||
"description": "Port the pitcher CSV importer (similar structure to batter but with endurance parsing, different column names, and pitcher-specific fields). Also port import_all_cards orchestrator that runs both imports and defaults CSV paths.",
|
||||
"category": "medium",
|
||||
"priority": 11,
|
||||
"completed": false,
|
||||
"tested": false,
|
||||
"dependencies": ["MED-002"],
|
||||
"files": [
|
||||
{
|
||||
"path": "rust/src/api/importer.rs",
|
||||
"lines": [],
|
||||
"issue": "File doesn't exist yet"
|
||||
}
|
||||
],
|
||||
"suggestedFix": "Implement `pub async fn import_pitcher_cards(pool: &SqlitePool, csv_path: &Path, update_existing: bool) -> Result<ImportResult>`:\n\nSame structure as batter import with key differences:\n1. player.hand validated as L/R only (no S for pitchers)\n2. CSV 'vlhp' columns map to DB 'vlhb' fields (vs Left-Handed Batters)\n3. Endurance parsing: try columns 'ENDURANCE' or 'cleanEndur' first with parse_endurance(), then override with individual 'SP'/'RP'/'CP' columns if present (parse_int)\n4. Additional fields: hold_rating ('HO'), fielding_range ('Range'), fielding_error ('Error'), wild_pitch ('WP'), balk ('BK'), batting_rating ('BAT-B')\n5. Watch for 'BP1b v rhp' — lowercase 'b' in CSV header\n6. 'BPHR vL' and 'BPHR vR' (not 'BPHR v lhp'/'BPHR v rhp') for pitcher ballpark HR columns\n\nImplement `pub async fn import_all_cards(pool: &SqlitePool, batter_csv: Option<&Path>, pitcher_csv: Option<&Path>, update_existing: bool) -> Result<AllImportResult>`:\n1. Default paths: docs/sheets_export/BatterCalcs.csv and PitcherCalcs.csv\n2. Run batter import, then pitcher import\n3. Return combined results\n4. Note: Python version calls rebuild_score_cache() after import — defer this to Phase 3 (calc layer) and add a TODO comment.",
|
||||
"estimatedHours": 2.5,
|
||||
"notes": "The pitcher importer has more gotchas than the batter importer. The endurance column fallback logic (ENDURANCE → cleanEndur → individual SP/RP/CP) must be implemented carefully. Extract shared CSV reading logic (header lookup, record iteration, error collection) into helper functions to avoid duplication with the batter importer."
|
||||
}
|
||||
],
|
||||
"quickWins": [
|
||||
{
|
||||
"taskId": "CRIT-002",
|
||||
"estimatedMinutes": 20,
|
||||
"impact": "Clean error types make all other API code easier to write"
|
||||
},
|
||||
{
|
||||
"taskId": "MED-001",
|
||||
"estimatedMinutes": 30,
|
||||
"impact": "Pure functions with no dependencies — can be implemented and tested immediately"
|
||||
}
|
||||
],
|
||||
"productionBlockers": [
|
||||
{
|
||||
"taskId": "CRIT-001",
|
||||
"reason": "No response types = can't deserialize any API responses"
|
||||
},
|
||||
{
|
||||
"taskId": "CRIT-003",
|
||||
"reason": "No request method = can't call any endpoints"
|
||||
}
|
||||
],
|
||||
"weeklyRoadmap": {
|
||||
"session1": {
|
||||
"theme": "API Client Foundation",
|
||||
"tasks": ["CRIT-001", "CRIT-002", "CRIT-003", "HIGH-001"],
|
||||
"estimatedHours": 5.5,
|
||||
"notes": "Get the HTTP client fully working with typed responses. Test against the live API with cargo run."
|
||||
},
|
||||
"session2": {
|
||||
"theme": "Sync Pipeline",
|
||||
"tasks": ["HIGH-002", "HIGH-003", "HIGH-004", "HIGH-005"],
|
||||
"estimatedHours": 4.5,
|
||||
"notes": "All three sync functions plus the orchestrator. Test by syncing real data and querying with Phase 1 query functions."
|
||||
},
|
||||
"session3": {
|
||||
"theme": "CSV Import Pipeline",
|
||||
"tasks": ["MED-001", "MED-002", "MED-003"],
|
||||
"estimatedHours": 6,
|
||||
"notes": "Parse helpers, batter importer, pitcher importer. Test against existing BatterCalcs.csv and PitcherCalcs.csv files."
|
||||
}
|
||||
},
|
||||
"architecturalDecisions": {
|
||||
"serde_rename_for_field_mapping": "Use #[serde(rename = \"...\")] on API response structs to handle JSON↔Rust name mismatches at deserialization time, not at the sync layer. This keeps sync functions clean.",
|
||||
"player_hand_preservation": "sync_players must use ON CONFLICT DO UPDATE with explicit column list (omitting hand) instead of INSERT OR REPLACE, which would null out hand. Hand is populated only by CSV import.",
|
||||
"transaction_batching": "Wrap all sync and import operations in sqlx transactions for atomicity and performance. SQLite is much faster with batched inserts inside a transaction.",
|
||||
"error_collection_not_abort": "CSV import collects per-row errors into a Vec<String> and continues processing remaining rows, matching the Python behavior. Only truly fatal errors (file not found, DB connection lost) abort the entire import.",
|
||||
"csv_header_lookup": "Build a HashMap<String, usize> from CSV headers for O(1) column lookup by name. This handles the variant column names (cArm vs CA, ENDURANCE vs cleanEndur) cleanly.",
|
||||
"no_score_cache_rebuild_yet": "import_all_cards will NOT call rebuild_score_cache() — that belongs to Phase 3 (calc layer). Add a TODO comment at the call site."
|
||||
},
|
||||
"testingStrategy": {
|
||||
"api_client": "Write integration tests that call the live API (behind a #[cfg(feature = \"integration\")] gate or #[ignore] attribute). Test at minimum: get_teams, get_players, get_current. Verify serde deserialization works with real API responses.",
|
||||
"sync_functions": "Test with an in-memory SQLite database. Call sync, then verify rows were inserted using Phase 1 query functions. Mock the API client if feasible, otherwise use #[ignore] for live tests.",
|
||||
"csv_helpers": "Full unit test coverage for parse_float, parse_int, parse_endurance with edge cases.",
|
||||
"csv_import": "Create small test CSV files (3-5 rows) with known values. Import into in-memory DB, then query and assert field values match. Test both insert and update paths."
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,25 @@
|
||||
use anyhow::Result;
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::types::*;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
#[error("HTTP {status}: {message}")]
|
||||
Http { status: u16, message: String },
|
||||
|
||||
#[error("Cloudflare blocked the request (403). The API may be down or rate-limiting.")]
|
||||
CloudflareBlocked,
|
||||
|
||||
#[error("Request failed: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
|
||||
#[error("JSON parse error: {0}")]
|
||||
Parse(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
pub struct LeagueApiClient {
|
||||
client: Client,
|
||||
base_url: String,
|
||||
@ -20,4 +38,188 @@ impl LeagueApiClient {
|
||||
api_key: api_key.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
params: &[(String, String)],
|
||||
) -> Result<T, ApiError> {
|
||||
let url = format!("{}/api/v3{}", self.base_url, path);
|
||||
let mut req = self.client.get(&url).query(params);
|
||||
if !self.api_key.is_empty() {
|
||||
req = req.bearer_auth(&self.api_key);
|
||||
}
|
||||
let response = req.send().await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status.as_u16() == 403 && body.to_lowercase().contains("cloudflare") {
|
||||
return Err(ApiError::CloudflareBlocked);
|
||||
}
|
||||
return Err(ApiError::Http { status: status.as_u16(), message: body });
|
||||
}
|
||||
let body = response.text().await.map_err(ApiError::Request)?;
|
||||
let json = serde_json::from_str::<T>(&body)?;
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public endpoint methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_teams(
|
||||
&self,
|
||||
season: Option<i64>,
|
||||
team_abbrev: Option<&[&str]>,
|
||||
active_only: bool,
|
||||
short_output: bool,
|
||||
) -> Result<TeamsResponse, ApiError> {
|
||||
let mut params: Vec<(String, String)> = Vec::new();
|
||||
if let Some(s) = season {
|
||||
params.push(("season".to_string(), s.to_string()));
|
||||
}
|
||||
if let Some(abbrevs) = team_abbrev {
|
||||
for a in abbrevs {
|
||||
params.push(("team_abbrev".to_string(), a.to_string()));
|
||||
}
|
||||
}
|
||||
if active_only {
|
||||
params.push(("active_only".to_string(), "true".to_string()));
|
||||
}
|
||||
if short_output {
|
||||
params.push(("short_output".to_string(), "true".to_string()));
|
||||
}
|
||||
self.get("/teams", ¶ms).await
|
||||
}
|
||||
|
||||
pub async fn get_team(&self, team_id: i64) -> Result<TeamData, ApiError> {
|
||||
self.get(&format!("/teams/{}", team_id), &[]).await
|
||||
}
|
||||
|
||||
pub async fn get_team_roster(
|
||||
&self,
|
||||
team_id: i64,
|
||||
which: &str,
|
||||
) -> Result<serde_json::Value, ApiError> {
|
||||
self.get(&format!("/teams/{}/roster/{}", team_id, which), &[]).await
|
||||
}
|
||||
|
||||
pub async fn get_players(
|
||||
&self,
|
||||
season: Option<i64>,
|
||||
team_id: Option<&[i64]>,
|
||||
pos: Option<&[&str]>,
|
||||
name: Option<&str>,
|
||||
short_output: bool,
|
||||
) -> Result<PlayersResponse, ApiError> {
|
||||
let mut params: Vec<(String, String)> = Vec::new();
|
||||
if let Some(s) = season {
|
||||
params.push(("season".to_string(), s.to_string()));
|
||||
}
|
||||
if let Some(ids) = team_id {
|
||||
for id in ids {
|
||||
params.push(("team_id".to_string(), id.to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(positions) = pos {
|
||||
for p in positions {
|
||||
params.push(("pos".to_string(), p.to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(n) = name {
|
||||
params.push(("name".to_string(), n.to_string()));
|
||||
}
|
||||
if short_output {
|
||||
params.push(("short_output".to_string(), "true".to_string()));
|
||||
}
|
||||
self.get("/players", ¶ms).await
|
||||
}
|
||||
|
||||
pub async fn get_player(
|
||||
&self,
|
||||
player_id: i64,
|
||||
short_output: bool,
|
||||
) -> Result<PlayerData, ApiError> {
|
||||
let mut params: Vec<(String, String)> = Vec::new();
|
||||
if short_output {
|
||||
params.push(("short_output".to_string(), "true".to_string()));
|
||||
}
|
||||
self.get(&format!("/players/{}", player_id), ¶ms).await
|
||||
}
|
||||
|
||||
pub async fn search_players(
|
||||
&self,
|
||||
query: &str,
|
||||
season: Option<i64>,
|
||||
limit: Option<i64>,
|
||||
) -> Result<PlayersResponse, ApiError> {
|
||||
let mut params: Vec<(String, String)> = Vec::new();
|
||||
params.push(("q".to_string(), query.to_string()));
|
||||
if let Some(s) = season {
|
||||
params.push(("season".to_string(), s.to_string()));
|
||||
}
|
||||
if let Some(l) = limit {
|
||||
params.push(("limit".to_string(), l.to_string()));
|
||||
}
|
||||
self.get("/players/search", ¶ms).await
|
||||
}
|
||||
|
||||
pub async fn get_transactions(
|
||||
&self,
|
||||
season: i64,
|
||||
week_start: i64,
|
||||
week_end: Option<i64>,
|
||||
team_abbrev: Option<&[&str]>,
|
||||
cancelled: bool,
|
||||
frozen: bool,
|
||||
short_output: bool,
|
||||
) -> Result<TransactionsResponse, ApiError> {
|
||||
let mut params: Vec<(String, String)> = Vec::new();
|
||||
params.push(("season".to_string(), season.to_string()));
|
||||
params.push(("week_start".to_string(), week_start.to_string()));
|
||||
if let Some(abbrevs) = team_abbrev {
|
||||
for a in abbrevs {
|
||||
params.push(("team_abbrev".to_string(), a.to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(we) = week_end {
|
||||
params.push(("week_end".to_string(), we.to_string()));
|
||||
}
|
||||
if cancelled {
|
||||
params.push(("cancelled".to_string(), "true".to_string()));
|
||||
}
|
||||
if frozen {
|
||||
params.push(("frozen".to_string(), "true".to_string()));
|
||||
}
|
||||
if short_output {
|
||||
params.push(("short_output".to_string(), "true".to_string()));
|
||||
}
|
||||
self.get("/transactions", ¶ms).await
|
||||
}
|
||||
|
||||
pub async fn get_current(&self) -> Result<CurrentResponse, ApiError> {
|
||||
self.get("/current", &[]).await
|
||||
}
|
||||
|
||||
pub async fn get_schedule(
|
||||
&self,
|
||||
season: i64,
|
||||
week: Option<i64>,
|
||||
team_id: Option<i64>,
|
||||
) -> Result<serde_json::Value, ApiError> {
|
||||
let mut params: Vec<(String, String)> = Vec::new();
|
||||
params.push(("season".to_string(), season.to_string()));
|
||||
if let Some(w) = week {
|
||||
params.push(("week".to_string(), w.to_string()));
|
||||
}
|
||||
if let Some(t) = team_id {
|
||||
params.push(("team_id".to_string(), t.to_string()));
|
||||
}
|
||||
self.get("/schedules", ¶ms).await
|
||||
}
|
||||
|
||||
pub async fn get_standings(&self, season: i64) -> Result<serde_json::Value, ApiError> {
|
||||
let params = vec![("season".to_string(), season.to_string())];
|
||||
self.get("/standings", ¶ms).await
|
||||
}
|
||||
}
|
||||
|
||||
666
rust/src/api/importer.rs
Normal file
666
rust/src/api/importer.rs
Normal file
@ -0,0 +1,666 @@
|
||||
//! CSV card data importer for Strat-o-Matic card values.
|
||||
//!
|
||||
//! Ports the Python `api/importer.py` helpers and import functions.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Outcome of a single CSV import operation (batters or pitchers).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ImportResult {
|
||||
pub imported: usize,
|
||||
pub skipped: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Combined result from importing both batter and pitcher card CSVs.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AllImportResult {
|
||||
pub batters: ImportResult,
|
||||
pub pitchers: ImportResult,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compiled regexes (stable LazyLock since Rust 1.80)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static RE_START: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"S\((\d+)\*?\)").expect("valid regex"));
|
||||
|
||||
static RE_RELIEF: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"R\((\d+)\)").expect("valid regex"));
|
||||
|
||||
static RE_CLOSE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"C\((\d+)\)").expect("valid regex"));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Safely parse a float from a string, returning `default` on failure or empty input.
|
||||
pub fn parse_float(value: &str, default: f64) -> f64 {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return default;
|
||||
}
|
||||
trimmed.parse::<f64>().unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Safely parse an integer from a string, returning `default` on failure or empty input.
|
||||
///
|
||||
/// Parses as `f64` first so that values like `"5.0"` are handled correctly.
|
||||
pub fn parse_int(value: &str, default: i32) -> i32 {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return default;
|
||||
}
|
||||
match trimmed.parse::<f64>() {
|
||||
Ok(f) => f as i32,
|
||||
Err(_) => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an endurance string like `"S(5) R(4)"` or `"R(1) C(6)"`.
|
||||
///
|
||||
/// Returns `(start, relief, close)` — any component may be `None` if absent.
|
||||
/// Handles starred starters like `"S(5*)"`.
|
||||
pub fn parse_endurance(value: &str) -> (Option<i32>, Option<i32>, Option<i32>) {
|
||||
if value.trim().is_empty() {
|
||||
return (None, None, None);
|
||||
}
|
||||
|
||||
let start = RE_START
|
||||
.captures(value)
|
||||
.and_then(|c| c.get(1))
|
||||
.and_then(|m| m.as_str().parse::<i32>().ok());
|
||||
|
||||
let relief = RE_RELIEF
|
||||
.captures(value)
|
||||
.and_then(|c| c.get(1))
|
||||
.and_then(|m| m.as_str().parse::<i32>().ok());
|
||||
|
||||
let close = RE_CLOSE
|
||||
.captures(value)
|
||||
.and_then(|c| c.get(1))
|
||||
.and_then(|m| m.as_str().parse::<i32>().ok());
|
||||
|
||||
(start, relief, close)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Import batter card data from a CSV file into the database.
|
||||
///
|
||||
/// For each row the function:
|
||||
/// 1. Parses `player_id` — skips rows where it is 0 or missing.
|
||||
/// 2. Verifies the player exists; records an error and continues if not.
|
||||
/// 3. Updates `players.hand` when the CSV supplies "L", "R", or "S".
|
||||
/// 4. Skips the row (incrementing `skipped`) when `update_existing` is false
|
||||
/// and a `batter_cards` row already exists for this player.
|
||||
/// 5. Inserts or replaces the batter card with all mapped fields.
|
||||
///
|
||||
/// All DB writes happen inside a single transaction.
|
||||
pub async fn import_batter_cards(
|
||||
pool: &SqlitePool,
|
||||
csv_path: &Path,
|
||||
update_existing: bool,
|
||||
) -> Result<ImportResult> {
|
||||
let mut result = ImportResult::default();
|
||||
|
||||
let source = csv_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let mut rdr = csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)?;
|
||||
|
||||
// Build O(1) header → column-index lookup before iterating records.
|
||||
let headers = rdr.headers()?.clone();
|
||||
let header_index: HashMap<String, usize> =
|
||||
headers.iter().enumerate().map(|(i, h)| (h.to_string(), i)).collect();
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
for record_result in rdr.records() {
|
||||
let record = match record_result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
result.errors.push(format!("CSV parse error: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Returns the field value for `name`, or "" if the column is absent.
|
||||
let get = |name: &str| -> &str {
|
||||
header_index.get(name).and_then(|&i| record.get(i)).unwrap_or("")
|
||||
};
|
||||
|
||||
let player_id = parse_int(get("player_id"), 0);
|
||||
if player_id == 0 {
|
||||
continue;
|
||||
}
|
||||
let player_id = player_id as i64;
|
||||
|
||||
// Lightweight existence check — no need to SELECT the full row.
|
||||
let player_exists: Option<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM players WHERE id = ?")
|
||||
.bind(player_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if player_exists.is_none() {
|
||||
result.errors.push(format!(
|
||||
"Player {} ({}) not found in database",
|
||||
player_id,
|
||||
get("Name")
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update batting hand when the CSV provides a valid value.
|
||||
let hand = get("hand").trim();
|
||||
if matches!(hand, "L" | "R" | "S") {
|
||||
sqlx::query("UPDATE players SET hand = ? WHERE id = ?")
|
||||
.bind(hand)
|
||||
.bind(player_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Skip if card already exists and caller asked not to overwrite.
|
||||
if !update_existing {
|
||||
let existing: Option<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM batter_cards WHERE player_id = ?")
|
||||
.bind(player_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing.is_some() {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// cArm and CA are alternate column names for catcher arm rating.
|
||||
let catcher_arm_str = {
|
||||
let v = get("cArm");
|
||||
if v.trim().is_empty() { get("CA") } else { v }
|
||||
};
|
||||
let catcher_arm: Option<i64> = if catcher_arm_str.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parse_int(catcher_arm_str, 0) as i64)
|
||||
};
|
||||
|
||||
// Helpers for optional DB fields.
|
||||
let opt_str = |v: &str| -> Option<String> {
|
||||
let t = v.trim();
|
||||
if t.is_empty() { None } else { Some(t.to_string()) }
|
||||
};
|
||||
let opt_float = |v: &str| -> Option<f64> {
|
||||
if v.trim().is_empty() { None } else { Some(parse_float(v, 0.0)) }
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO batter_cards (
|
||||
player_id,
|
||||
so_vlhp, bb_vlhp, hit_vlhp, ob_vlhp, tb_vlhp, hr_vlhp, dp_vlhp,
|
||||
bphr_vlhp, bp1b_vlhp,
|
||||
so_vrhp, bb_vrhp, hit_vrhp, ob_vrhp, tb_vrhp, hr_vrhp, dp_vrhp,
|
||||
bphr_vrhp, bp1b_vrhp,
|
||||
stealing, steal_rating, speed,
|
||||
bunt, hit_run, fielding,
|
||||
catcher_arm,
|
||||
rating_vl, rating_vr, rating_overall,
|
||||
imported_at, source
|
||||
) VALUES (
|
||||
?,
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?,
|
||||
?, ?, ?,
|
||||
?,
|
||||
?, ?, ?,
|
||||
CURRENT_TIMESTAMP, ?
|
||||
)",
|
||||
)
|
||||
.bind(player_id)
|
||||
// vs LHP
|
||||
.bind(parse_float(get("SO vlhp"), 0.0))
|
||||
.bind(parse_float(get("BB v lhp"), 0.0))
|
||||
.bind(parse_float(get("HIT v lhp"), 0.0))
|
||||
.bind(parse_float(get("OB v lhp"), 0.0))
|
||||
.bind(parse_float(get("TB v lhp"), 0.0))
|
||||
.bind(parse_float(get("HR v lhp"), 0.0))
|
||||
.bind(parse_float(get("DP v lhp"), 0.0))
|
||||
.bind(parse_float(get("BPHR v lhp"), 0.0))
|
||||
.bind(parse_float(get("BP1B v lhp"), 0.0))
|
||||
// vs RHP
|
||||
.bind(parse_float(get("SO v rhp"), 0.0))
|
||||
.bind(parse_float(get("BB v rhp"), 0.0))
|
||||
.bind(parse_float(get("HIT v rhp"), 0.0))
|
||||
.bind(parse_float(get("OB v rhp"), 0.0))
|
||||
.bind(parse_float(get("TB v rhp"), 0.0))
|
||||
.bind(parse_float(get("HR v rhp"), 0.0))
|
||||
.bind(parse_float(get("DP v rhp"), 0.0))
|
||||
.bind(parse_float(get("BPHR v rhp"), 0.0))
|
||||
.bind(parse_float(get("BP1B v rhp"), 0.0))
|
||||
// Running game
|
||||
.bind(opt_str(get("STEALING")))
|
||||
.bind(opt_str(get("STL")))
|
||||
.bind(parse_int(get("SPD"), 10) as i64)
|
||||
// Batting extras
|
||||
.bind(opt_str(get("B")))
|
||||
.bind(opt_str(get("H")))
|
||||
// Fielding
|
||||
.bind(opt_str(get("FIELDING")))
|
||||
// Catcher
|
||||
.bind(catcher_arm)
|
||||
// Pre-calculated ratings from the spreadsheet
|
||||
.bind(opt_float(get("vL")))
|
||||
.bind(opt_float(get("vR")))
|
||||
.bind(opt_float(get("Total")))
|
||||
// Metadata
|
||||
.bind(&source)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
result.imported += 1;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Import pitcher card data from a CSV file into the database.
|
||||
///
|
||||
/// For each row the function:
|
||||
/// 1. Parses `player_id` — skips rows where it is 0 or missing.
|
||||
/// 2. Verifies the player exists; records an error and continues if not.
|
||||
/// 3. Updates `players.hand` when the CSV supplies "L" or "R" (no switch for pitchers).
|
||||
/// 4. Skips the row (incrementing `skipped`) when `update_existing` is false
|
||||
/// and a `pitcher_cards` row already exists for this player.
|
||||
/// 5. Parses endurance from the "ENDURANCE"/"cleanEndur" column first, then
|
||||
/// overrides individual components if "SP"/"RP"/"CP" columns are present.
|
||||
/// 6. Inserts or replaces the pitcher card with all mapped fields.
|
||||
///
|
||||
/// Note: The CSV uses "vlhp" in column names (vs Left-Handed Pitchers), but the
|
||||
/// pitcher card DB fields use "vlhb" (vs Left-Handed Batters). This naming
|
||||
/// inconsistency is an artifact of the source spreadsheet.
|
||||
///
|
||||
/// All DB writes happen inside a single transaction.
|
||||
pub async fn import_pitcher_cards(
|
||||
pool: &SqlitePool,
|
||||
csv_path: &Path,
|
||||
update_existing: bool,
|
||||
) -> Result<ImportResult> {
|
||||
let mut result = ImportResult::default();
|
||||
|
||||
let source = csv_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let mut rdr = csv::ReaderBuilder::new().has_headers(true).from_path(csv_path)?;
|
||||
|
||||
let headers = rdr.headers()?.clone();
|
||||
let header_index: HashMap<String, usize> =
|
||||
headers.iter().enumerate().map(|(i, h)| (h.to_string(), i)).collect();
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
for record_result in rdr.records() {
|
||||
let record = match record_result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
result.errors.push(format!("CSV parse error: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let get = |name: &str| -> &str {
|
||||
header_index.get(name).and_then(|&i| record.get(i)).unwrap_or("")
|
||||
};
|
||||
|
||||
let player_id = parse_int(get("player_id"), 0);
|
||||
if player_id == 0 {
|
||||
continue;
|
||||
}
|
||||
let player_id = player_id as i64;
|
||||
|
||||
let player_exists: Option<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM players WHERE id = ?")
|
||||
.bind(player_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if player_exists.is_none() {
|
||||
result.errors.push(format!(
|
||||
"Player {} ({}) not found in database",
|
||||
player_id,
|
||||
get("Name")
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pitchers only throw L or R — no switch pitchers.
|
||||
let hand = get("hand").trim();
|
||||
if matches!(hand, "L" | "R") {
|
||||
sqlx::query("UPDATE players SET hand = ? WHERE id = ?")
|
||||
.bind(hand)
|
||||
.bind(player_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !update_existing {
|
||||
let existing: Option<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM pitcher_cards WHERE player_id = ?")
|
||||
.bind(player_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if existing.is_some() {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse endurance from the combined column first, then override with
|
||||
// individual SP/RP/CP columns if they are non-empty.
|
||||
let endur_raw = {
|
||||
let v = get("ENDURANCE");
|
||||
if v.trim().is_empty() { get("cleanEndur") } else { v }
|
||||
};
|
||||
let (mut endur_start, mut endur_relief, mut endur_close) = parse_endurance(endur_raw);
|
||||
|
||||
let sp_raw = get("SP").trim();
|
||||
if !sp_raw.is_empty() {
|
||||
endur_start = Some(parse_int(sp_raw, 0));
|
||||
}
|
||||
let rp_raw = get("RP").trim();
|
||||
if !rp_raw.is_empty() {
|
||||
endur_relief = Some(parse_int(rp_raw, 0));
|
||||
}
|
||||
let cp_raw = get("CP").trim();
|
||||
if !cp_raw.is_empty() {
|
||||
endur_close = Some(parse_int(cp_raw, 0));
|
||||
}
|
||||
|
||||
let opt_str = |v: &str| -> Option<String> {
|
||||
let t = v.trim();
|
||||
if t.is_empty() { None } else { Some(t.to_string()) }
|
||||
};
|
||||
let opt_float = |v: &str| -> Option<f64> {
|
||||
if v.trim().is_empty() { None } else { Some(parse_float(v, 0.0)) }
|
||||
};
|
||||
let opt_int = |v: &str| -> Option<i64> {
|
||||
if v.trim().is_empty() { None } else { Some(parse_int(v, 0) as i64) }
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO pitcher_cards (
|
||||
player_id,
|
||||
so_vlhb, bb_vlhb, hit_vlhb, ob_vlhb, tb_vlhb, hr_vlhb, dp_vlhb,
|
||||
bphr_vlhb, bp1b_vlhb,
|
||||
so_vrhb, bb_vrhb, hit_vrhb, ob_vrhb, tb_vrhb, hr_vrhb, dp_vrhb,
|
||||
bphr_vrhb, bp1b_vrhb,
|
||||
hold_rating,
|
||||
endurance_start, endurance_relief, endurance_close,
|
||||
fielding_range, fielding_error,
|
||||
wild_pitch, balk,
|
||||
batting_rating,
|
||||
rating_vlhb, rating_vrhb, rating_overall,
|
||||
imported_at, source
|
||||
) VALUES (
|
||||
?,
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?,
|
||||
?,
|
||||
?, ?, ?,
|
||||
?, ?,
|
||||
?, ?,
|
||||
?,
|
||||
?, ?, ?,
|
||||
CURRENT_TIMESTAMP, ?
|
||||
)",
|
||||
)
|
||||
.bind(player_id)
|
||||
// vs Left-Handed Batters (CSV uses "vlhp" suffix, DB uses "vlhb")
|
||||
.bind(parse_float(get("SO vlhp"), 0.0))
|
||||
.bind(parse_float(get("BB vlhp"), 0.0))
|
||||
.bind(parse_float(get("HIT v lhp"), 0.0))
|
||||
.bind(parse_float(get("OB v lhp"), 0.0))
|
||||
.bind(parse_float(get("TB v lhp"), 0.0))
|
||||
.bind(parse_float(get("HR v lhp"), 0.0))
|
||||
.bind(parse_float(get("DP vlhp"), 0.0)) // no space after DP
|
||||
.bind(parse_float(get("BPHR vL"), 0.0)) // different format from batter
|
||||
.bind(parse_float(get("BP1B v lhp"), 0.0))
|
||||
// vs Right-Handed Batters
|
||||
.bind(parse_float(get("SO v rhp"), 0.0))
|
||||
.bind(parse_float(get("BB v rhp"), 0.0))
|
||||
.bind(parse_float(get("HIT v rhp"), 0.0))
|
||||
.bind(parse_float(get("OB v rhp"), 0.0))
|
||||
.bind(parse_float(get("TB v rhp"), 0.0))
|
||||
.bind(parse_float(get("HR v rhp"), 0.0))
|
||||
.bind(parse_float(get("DP v rhp"), 0.0))
|
||||
.bind(parse_float(get("BPHR vR"), 0.0)) // different format from batter
|
||||
.bind(parse_float(get("BP1b v rhp"), 0.0)) // lowercase 'b'
|
||||
// Pitcher attributes
|
||||
.bind(parse_int(get("HO"), 0) as i64)
|
||||
.bind(endur_start.map(|v| v as i64))
|
||||
.bind(endur_relief.map(|v| v as i64))
|
||||
.bind(endur_close.map(|v| v as i64))
|
||||
.bind(opt_int(get("Range")))
|
||||
.bind(opt_int(get("Error")))
|
||||
.bind(parse_int(get("WP"), 0) as i64)
|
||||
.bind(parse_int(get("BK"), 0) as i64)
|
||||
.bind(opt_str(get("BAT-B")))
|
||||
// Pre-calculated ratings from the spreadsheet
|
||||
.bind(opt_float(get("vL")))
|
||||
.bind(opt_float(get("vR")))
|
||||
.bind(opt_float(get("Total")))
|
||||
// Metadata
|
||||
.bind(&source)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
result.imported += 1;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Import both batter and pitcher card CSVs in sequence.
|
||||
///
|
||||
/// Default paths are `docs/sheets_export/BatterCalcs.csv` and
|
||||
/// `docs/sheets_export/PitcherCalcs.csv`. Pass `None` to use the defaults.
|
||||
pub async fn import_all_cards(
|
||||
pool: &SqlitePool,
|
||||
batter_csv: Option<&Path>,
|
||||
pitcher_csv: Option<&Path>,
|
||||
update_existing: bool,
|
||||
) -> Result<AllImportResult> {
|
||||
let default_batter = Path::new("docs/sheets_export/BatterCalcs.csv");
|
||||
let default_pitcher = Path::new("docs/sheets_export/PitcherCalcs.csv");
|
||||
|
||||
let batter_path = batter_csv.unwrap_or(default_batter);
|
||||
let pitcher_path = pitcher_csv.unwrap_or(default_pitcher);
|
||||
|
||||
// Import each CSV independently — if one is missing, still attempt the other.
|
||||
let batters = match import_batter_cards(pool, batter_path, update_existing).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("Batter CSV import failed ({}): {}", batter_path.display(), e);
|
||||
ImportResult { imported: 0, skipped: 0, errors: vec![format!("Batter import failed: {e}")] }
|
||||
}
|
||||
};
|
||||
let pitchers = match import_pitcher_cards(pool, pitcher_path, update_existing).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
eprintln!("Pitcher CSV import failed ({}): {}", pitcher_path.display(), e);
|
||||
ImportResult { imported: 0, skipped: 0, errors: vec![format!("Pitcher import failed: {e}")] }
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: Phase 3 — call rebuild_score_cache() after import
|
||||
|
||||
Ok(AllImportResult { batters, pitchers })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// --- parse_float ---
|
||||
|
||||
/// Normal float string should parse correctly.
|
||||
#[test]
|
||||
fn parse_float_normal() {
|
||||
assert_eq!(parse_float("3.14", 0.0), 3.14);
|
||||
}
|
||||
|
||||
/// Integer-looking string should parse as float.
|
||||
#[test]
|
||||
fn parse_float_integer_string() {
|
||||
assert_eq!(parse_float("5", 0.0), 5.0);
|
||||
}
|
||||
|
||||
/// Empty string returns the default.
|
||||
#[test]
|
||||
fn parse_float_empty() {
|
||||
assert_eq!(parse_float("", 99.0), 99.0);
|
||||
}
|
||||
|
||||
/// Whitespace-only string returns the default.
|
||||
#[test]
|
||||
fn parse_float_whitespace() {
|
||||
assert_eq!(parse_float(" ", -1.0), -1.0);
|
||||
}
|
||||
|
||||
/// Non-numeric string returns the default.
|
||||
#[test]
|
||||
fn parse_float_unparseable() {
|
||||
assert_eq!(parse_float("N/A", 0.0), 0.0);
|
||||
}
|
||||
|
||||
// --- parse_int ---
|
||||
|
||||
/// Normal integer string should parse correctly.
|
||||
#[test]
|
||||
fn parse_int_normal() {
|
||||
assert_eq!(parse_int("7", 0), 7);
|
||||
}
|
||||
|
||||
/// Float-formatted integer like "5.0" should truncate to 5.
|
||||
#[test]
|
||||
fn parse_int_float_string() {
|
||||
assert_eq!(parse_int("5.0", 0), 5);
|
||||
}
|
||||
|
||||
/// Float with fractional part truncates toward zero.
|
||||
#[test]
|
||||
fn parse_int_float_fractional() {
|
||||
assert_eq!(parse_int("3.9", 0), 3);
|
||||
}
|
||||
|
||||
/// Empty string returns the default.
|
||||
#[test]
|
||||
fn parse_int_empty() {
|
||||
assert_eq!(parse_int("", 42), 42);
|
||||
}
|
||||
|
||||
/// Whitespace-only string returns the default.
|
||||
#[test]
|
||||
fn parse_int_whitespace() {
|
||||
assert_eq!(parse_int(" ", -1), -1);
|
||||
}
|
||||
|
||||
/// Non-numeric string returns the default.
|
||||
#[test]
|
||||
fn parse_int_unparseable() {
|
||||
assert_eq!(parse_int("abc", 0), 0);
|
||||
}
|
||||
|
||||
// --- parse_endurance ---
|
||||
|
||||
/// Start-only endurance string.
|
||||
#[test]
|
||||
fn parse_endurance_start_only() {
|
||||
assert_eq!(parse_endurance("S(5)"), (Some(5), None, None));
|
||||
}
|
||||
|
||||
/// Relief-only endurance string.
|
||||
#[test]
|
||||
fn parse_endurance_relief_only() {
|
||||
assert_eq!(parse_endurance("R(4)"), (None, Some(4), None));
|
||||
}
|
||||
|
||||
/// Close-only endurance string.
|
||||
#[test]
|
||||
fn parse_endurance_close_only() {
|
||||
assert_eq!(parse_endurance("C(6)"), (None, None, Some(6)));
|
||||
}
|
||||
|
||||
/// Start + Relief combination.
|
||||
#[test]
|
||||
fn parse_endurance_start_relief() {
|
||||
assert_eq!(parse_endurance("S(5) R(4)"), (Some(5), Some(4), None));
|
||||
}
|
||||
|
||||
/// Relief + Close combination.
|
||||
#[test]
|
||||
fn parse_endurance_relief_close() {
|
||||
assert_eq!(parse_endurance("R(1) C(6)"), (None, Some(1), Some(6)));
|
||||
}
|
||||
|
||||
/// All three components present.
|
||||
#[test]
|
||||
fn parse_endurance_all() {
|
||||
assert_eq!(parse_endurance("S(5) R(3) C(6)"), (Some(5), Some(3), Some(6)));
|
||||
}
|
||||
|
||||
/// Starred starter notation "S(5*)" should match start = 5.
|
||||
#[test]
|
||||
fn parse_endurance_starred_starter() {
|
||||
assert_eq!(parse_endurance("S(5*)"), (Some(5), None, None));
|
||||
}
|
||||
|
||||
/// Starred starter with relief component.
|
||||
#[test]
|
||||
fn parse_endurance_starred_with_relief() {
|
||||
assert_eq!(parse_endurance("S(5*) R(3) C(6)"), (Some(5), Some(3), Some(6)));
|
||||
}
|
||||
|
||||
/// Empty string returns all None.
|
||||
#[test]
|
||||
fn parse_endurance_empty() {
|
||||
assert_eq!(parse_endurance(""), (None, None, None));
|
||||
}
|
||||
|
||||
/// Whitespace-only returns all None.
|
||||
#[test]
|
||||
fn parse_endurance_whitespace() {
|
||||
assert_eq!(parse_endurance(" "), (None, None, None));
|
||||
}
|
||||
}
|
||||
@ -1 +1,4 @@
|
||||
pub mod client;
|
||||
pub mod importer;
|
||||
pub mod sync;
|
||||
pub mod types;
|
||||
|
||||
243
rust/src/api/sync.rs
Normal file
243
rust/src/api/sync.rs
Normal file
@ -0,0 +1,243 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use super::client::LeagueApiClient;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SyncResult {
|
||||
pub teams: i64,
|
||||
pub players: i64,
|
||||
pub transactions: i64,
|
||||
}
|
||||
|
||||
/// Sync all teams from the league API for the given season.
|
||||
/// Uses INSERT OR REPLACE to handle both inserts and updates atomically.
|
||||
pub async fn sync_teams(pool: &SqlitePool, season: i64, client: &LeagueApiClient) -> Result<i64> {
|
||||
let response = client.get_teams(Some(season), None, true, false).await?;
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
let mut count: i64 = 0;
|
||||
let synced_at = chrono::Utc::now().naive_utc();
|
||||
|
||||
for data in response.teams {
|
||||
let manager1_name = data.manager1.and_then(|m| m.name);
|
||||
let manager2_name = data.manager2.and_then(|m| m.name);
|
||||
let gm_discord_id = data.gm_discord_id.map(|id| id.to_string());
|
||||
let gm2_discord_id = data.gm2_discord_id.map(|id| id.to_string());
|
||||
let division_id = data.division.as_ref().map(|d| d.id).flatten();
|
||||
let division_name = data.division.as_ref().and_then(|d| d.division_name.clone());
|
||||
let league_abbrev = data.division.as_ref().and_then(|d| d.league_abbrev.clone());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO teams \
|
||||
(id, abbrev, short_name, long_name, season, manager1_name, manager2_name, \
|
||||
gm_discord_id, gm2_discord_id, division_id, division_name, league_abbrev, \
|
||||
thumbnail, color, dice_color, stadium, salary_cap, synced_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(data.id)
|
||||
.bind(data.abbrev.unwrap_or_default())
|
||||
.bind(data.short_name.unwrap_or_default())
|
||||
.bind(data.long_name.unwrap_or_default())
|
||||
.bind(season)
|
||||
.bind(manager1_name)
|
||||
.bind(manager2_name)
|
||||
.bind(gm_discord_id)
|
||||
.bind(gm2_discord_id)
|
||||
.bind(division_id)
|
||||
.bind(division_name)
|
||||
.bind(league_abbrev)
|
||||
.bind(data.thumbnail)
|
||||
.bind(data.color)
|
||||
.bind(data.dice_color)
|
||||
.bind(data.stadium)
|
||||
.bind(data.salary_cap)
|
||||
.bind(synced_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
count += 1;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
crate::db::queries::update_sync_status(pool, "teams", count, None).await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Sync players from the league API.
|
||||
/// Uses ON CONFLICT DO UPDATE with an explicit column list that intentionally omits `hand`,
|
||||
/// since `hand` is populated only from CSV card imports and must not be overwritten by the API.
|
||||
pub async fn sync_players(
|
||||
pool: &SqlitePool,
|
||||
season: i64,
|
||||
team_id: Option<i64>,
|
||||
client: &LeagueApiClient,
|
||||
) -> Result<i64> {
|
||||
let team_ids = team_id.map(|id| vec![id]);
|
||||
let response = client
|
||||
.get_players(Some(season), team_ids.as_deref(), None, None, false)
|
||||
.await?;
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
let mut count: i64 = 0;
|
||||
let synced_at = chrono::Utc::now().naive_utc();
|
||||
|
||||
for data in response.players {
|
||||
let player_team_id = data.team.map(|t| t.id);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO players \
|
||||
(id, name, season, team_id, swar, card_image, card_image_alt, headshot, vanity_card, \
|
||||
pos_1, pos_2, pos_3, pos_4, pos_5, pos_6, pos_7, pos_8, \
|
||||
injury_rating, il_return, demotion_week, strat_code, bbref_id, sbaplayer_id, \
|
||||
last_game, last_game2, synced_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(id) DO UPDATE SET \
|
||||
name=excluded.name, \
|
||||
season=excluded.season, \
|
||||
team_id=excluded.team_id, \
|
||||
swar=excluded.swar, \
|
||||
card_image=excluded.card_image, \
|
||||
card_image_alt=excluded.card_image_alt, \
|
||||
headshot=excluded.headshot, \
|
||||
vanity_card=excluded.vanity_card, \
|
||||
pos_1=excluded.pos_1, \
|
||||
pos_2=excluded.pos_2, \
|
||||
pos_3=excluded.pos_3, \
|
||||
pos_4=excluded.pos_4, \
|
||||
pos_5=excluded.pos_5, \
|
||||
pos_6=excluded.pos_6, \
|
||||
pos_7=excluded.pos_7, \
|
||||
pos_8=excluded.pos_8, \
|
||||
injury_rating=excluded.injury_rating, \
|
||||
il_return=excluded.il_return, \
|
||||
demotion_week=excluded.demotion_week, \
|
||||
strat_code=excluded.strat_code, \
|
||||
bbref_id=excluded.bbref_id, \
|
||||
sbaplayer_id=excluded.sbaplayer_id, \
|
||||
last_game=excluded.last_game, \
|
||||
last_game2=excluded.last_game2, \
|
||||
synced_at=excluded.synced_at",
|
||||
)
|
||||
.bind(data.id)
|
||||
.bind(data.name.unwrap_or_default())
|
||||
.bind(season)
|
||||
.bind(player_team_id)
|
||||
.bind(data.swar)
|
||||
.bind(data.card_image)
|
||||
.bind(data.card_image_alt)
|
||||
.bind(data.headshot)
|
||||
.bind(data.vanity_card)
|
||||
.bind(data.pos_1)
|
||||
.bind(data.pos_2)
|
||||
.bind(data.pos_3)
|
||||
.bind(data.pos_4)
|
||||
.bind(data.pos_5)
|
||||
.bind(data.pos_6)
|
||||
.bind(data.pos_7)
|
||||
.bind(data.pos_8)
|
||||
.bind(data.injury_rating)
|
||||
.bind(data.il_return)
|
||||
.bind(data.demotion_week)
|
||||
.bind(data.strat_code)
|
||||
.bind(data.bbref_id)
|
||||
.bind(data.sbaplayer_id)
|
||||
.bind(data.last_game)
|
||||
.bind(data.last_game2)
|
||||
.bind(synced_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
count += 1;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
crate::db::queries::update_sync_status(pool, "players", count, None).await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Sync transactions from the league API for the given season/week range.
|
||||
/// Skips rows where move_id or player is missing.
|
||||
/// ON CONFLICT only updates mutable fields (cancelled, frozen, synced_at) to preserve original
|
||||
/// season/week/team data.
|
||||
pub async fn sync_transactions(
|
||||
pool: &SqlitePool,
|
||||
season: i64,
|
||||
week_start: i64,
|
||||
week_end: Option<i64>,
|
||||
team_abbrev: Option<&str>,
|
||||
client: &LeagueApiClient,
|
||||
) -> Result<i64> {
|
||||
let abbrev_vec: Vec<&str> = team_abbrev.into_iter().collect();
|
||||
let abbrev_slice: Option<&[&str]> =
|
||||
if abbrev_vec.is_empty() { None } else { Some(&abbrev_vec) };
|
||||
|
||||
let response = client
|
||||
.get_transactions(season, week_start, week_end, abbrev_slice, false, false, false)
|
||||
.await?;
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
let mut count: i64 = 0;
|
||||
let synced_at = chrono::Utc::now().naive_utc();
|
||||
|
||||
for data in response.transactions {
|
||||
let (Some(move_id), Some(player)) = (data.move_id, data.player) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let move_id_str = move_id.to_string();
|
||||
let player_id = player.id;
|
||||
// Schema has from_team_id/to_team_id as NOT NULL; use 0 as sentinel for missing teams
|
||||
let from_team_id = data.oldteam.map(|t| t.id).unwrap_or(0);
|
||||
let to_team_id = data.newteam.map(|t| t.id).unwrap_or(0);
|
||||
let week = data.week.unwrap_or(0);
|
||||
let cancelled = data.cancelled.unwrap_or(false);
|
||||
let frozen = data.frozen.unwrap_or(false);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO transactions \
|
||||
(season, week, move_id, player_id, from_team_id, to_team_id, cancelled, frozen, \
|
||||
synced_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(move_id, player_id) DO UPDATE SET \
|
||||
cancelled=excluded.cancelled, \
|
||||
frozen=excluded.frozen, \
|
||||
synced_at=excluded.synced_at",
|
||||
)
|
||||
.bind(season)
|
||||
.bind(week)
|
||||
.bind(move_id_str)
|
||||
.bind(player_id)
|
||||
.bind(from_team_id)
|
||||
.bind(to_team_id)
|
||||
.bind(cancelled)
|
||||
.bind(frozen)
|
||||
.bind(synced_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
count += 1;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
crate::db::queries::update_sync_status(pool, "transactions", count, None).await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Orchestrate a full sync: teams, players, and transactions for the configured season.
|
||||
pub async fn sync_all(pool: &SqlitePool, settings: &crate::config::Settings) -> Result<SyncResult> {
|
||||
let client = LeagueApiClient::new(
|
||||
&settings.api.base_url,
|
||||
&settings.api.api_key,
|
||||
settings.api.timeout,
|
||||
)?;
|
||||
|
||||
let season = settings.team.season as i64;
|
||||
|
||||
let teams = sync_teams(pool, season, &client).await?;
|
||||
let players = sync_players(pool, season, None, &client).await?;
|
||||
// Start from week 1 (Python defaults to 0 which includes pre-season moves)
|
||||
let transactions = sync_transactions(pool, season, 1, None, None, &client).await?;
|
||||
|
||||
Ok(SyncResult { teams, players, transactions })
|
||||
}
|
||||
175
rust/src/api/types.rs
Normal file
175
rust/src/api/types.rs
Normal file
@ -0,0 +1,175 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
// =============================================================================
|
||||
// Shared nested types
|
||||
// =============================================================================
|
||||
|
||||
/// Minimal team reference used inside player/transaction responses.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TeamRef {
|
||||
pub id: i64,
|
||||
}
|
||||
|
||||
/// Minimal player reference used inside transaction responses.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PlayerRef {
|
||||
pub id: i64,
|
||||
}
|
||||
|
||||
/// Manager sub-object nested inside TeamData.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Manager {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Division sub-object nested inside TeamData.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Division {
|
||||
pub id: Option<i64>,
|
||||
pub division_name: Option<String>,
|
||||
pub league_abbrev: Option<String>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Teams
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TeamsResponse {
|
||||
pub count: u32,
|
||||
pub teams: Vec<TeamData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TeamData {
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub abbrev: Option<String>,
|
||||
#[serde(rename = "sname", default)]
|
||||
pub short_name: Option<String>,
|
||||
#[serde(rename = "lname", default)]
|
||||
pub long_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub thumbnail: Option<String>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dice_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stadium: Option<String>,
|
||||
#[serde(default)]
|
||||
pub salary_cap: Option<f64>,
|
||||
/// Discord user ID of the primary GM (API sends integer, DB stores as String).
|
||||
#[serde(rename = "gmid", default)]
|
||||
pub gm_discord_id: Option<i64>,
|
||||
/// Discord user ID of the secondary GM (API sends integer, DB stores as String).
|
||||
#[serde(rename = "gmid2", default)]
|
||||
pub gm2_discord_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub manager1: Option<Manager>,
|
||||
#[serde(default)]
|
||||
pub manager2: Option<Manager>,
|
||||
#[serde(default)]
|
||||
pub division: Option<Division>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Players
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PlayersResponse {
|
||||
pub count: u32,
|
||||
pub players: Vec<PlayerData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PlayerData {
|
||||
pub id: i64,
|
||||
pub name: Option<String>,
|
||||
pub headshot: Option<String>,
|
||||
pub vanity_card: Option<String>,
|
||||
/// Strat-O-Matic WAR equivalent — API field is "wara".
|
||||
#[serde(rename = "wara", default)]
|
||||
pub swar: Option<f64>,
|
||||
/// SBA player ID — API field is "sbaplayer".
|
||||
#[serde(rename = "sbaplayer", default)]
|
||||
pub sbaplayer_id: Option<i64>,
|
||||
/// Primary card image URL — API field is "image".
|
||||
#[serde(rename = "image", default)]
|
||||
pub card_image: Option<String>,
|
||||
/// Alternate card image URL — API field is "image2".
|
||||
#[serde(rename = "image2", default)]
|
||||
pub card_image_alt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub team: Option<TeamRef>,
|
||||
#[serde(default)]
|
||||
pub pos_1: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pos_2: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pos_3: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pos_4: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pos_5: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pos_6: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pos_7: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pos_8: Option<String>,
|
||||
#[serde(default)]
|
||||
pub injury_rating: Option<String>,
|
||||
#[serde(default)]
|
||||
pub il_return: Option<String>,
|
||||
#[serde(default)]
|
||||
pub demotion_week: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub strat_code: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bbref_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_game: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_game2: Option<String>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Transactions
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TransactionsResponse {
|
||||
pub count: u32,
|
||||
pub transactions: Vec<TransactionData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TransactionData {
|
||||
/// Transaction move ID — API field is "moveid".
|
||||
#[serde(rename = "moveid", default)]
|
||||
pub move_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub week: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub cancelled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub frozen: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub player: Option<PlayerRef>,
|
||||
#[serde(default)]
|
||||
pub oldteam: Option<TeamRef>,
|
||||
#[serde(default)]
|
||||
pub newteam: Option<TeamRef>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Current season info
|
||||
// =============================================================================
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CurrentResponse {
|
||||
pub season: i64,
|
||||
pub week: i64,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user