1use std::collections::HashMap;
4
5use axum::Json;
6use axum::extract::{Path, Query, State};
7use axum::http::{HeaderMap, HeaderValue, header};
8use axum::response::{IntoResponse, Response};
9use chess_core::{Game, PgnGame, PgnPlayer, Side, pgn};
10use chess_proto as p;
11
12use crate::app::Ctx;
13use crate::config::Config;
14use crate::error::{ApiError, ApiResult};
15use crate::model::{self, GameRow};
16
17fn flag(q: &HashMap<String, String>, k: &str, default: bool) -> bool {
18 q.get(k).map_or(default, |v| matches!(v.as_str(), "true" | "1" | "yes"))
19}
20
21fn pgn_text(row: &GameRow, game: &Game, cfg: &Config, clocks: bool) -> String {
22 let public_url = cfg.public_url();
23 let player = |s: Side| {
24 let a = row.player(s);
25 let (rating, _, diff) = row.rating(s);
26 PgnPlayer { name: a.username.clone(), rating: Some(rating), rating_diff: diff, title: a.title() }
27 };
28 let first_fullmove = row
29 .initial_fen
30 .as_deref()
31 .and_then(|f| f.split_whitespace().nth(5))
32 .and_then(|n| n.parse().ok())
33 .unwrap_or(1);
34 let event = format!("{} {} game", cfg.site_name, if row.rated { "rated" } else { "casual" });
35 let clocks_ms: Vec<i64> = if clocks && row.has_clock() { row.clock_ms.clone() } else { Vec::new() };
36 pgn(&PgnGame {
37 event: &event,
38 site: format!("{public_url}/{}", row.id),
39 game_id: &row.id,
40 created_at_ms: row.created_ms,
41 white: player(Side::White),
42 black: player(Side::Black),
43 status: row.status,
44 winner: row.winner,
45 clock: row
46 .clock_initial_ms
47 .map(|i| ((i / 1000) as u32, (row.clock_increment_ms.unwrap_or(0) / 1000) as u32)),
48 initial_fen: row.initial_fen.as_deref(),
49 sans: game.sans(),
50 first_to_move: game.first_to_move(),
51 first_fullmove,
52 clocks_ms: &clocks_ms,
53 })
54}
55
56pub(crate) fn json(row: &GameRow, game: &Game, q: &HashMap<String, String>, cfg: &Config) -> p::GameJson {
57 let player = |s: Side| {
58 let a = row.player(s);
59 let (rating, prov, diff) = row.rating(s);
60 p::GamePlayer {
61 user: p::LightUser { id: a.id(), name: a.username.clone(), title: a.title() },
62 rating,
63 rating_diff: diff,
64 provisional: prov.then_some(true),
65 analysis: None,
66 }
67 };
68 p::GameJson {
69 id: row.id.clone(),
70 rated: row.rated,
71 variant: row.variant.clone(),
72 speed: row.speed,
73 perf: row.perf_key().into(),
74 created_at: row.created_ms,
75 last_move_at: row.last_move_ms.or(row.finished_ms).unwrap_or(row.created_ms),
76 status: row.status,
77 source: row.source.clone(),
78 players: p::GamePlayers { white: player(Side::White), black: player(Side::Black) },
79 initial_fen: row.initial_fen.clone(),
80 winner: row.winner,
81 moves: flag(q, "moves", true).then(|| game.sans().join(" ")),
82 pgn: flag(q, "pgnInJson", false).then(|| pgn_text(row, game, cfg, flag(q, "clocks", false))),
83 clock: row.clock_initial_ms.map(|i| {
84 let inc = row.clock_increment_ms.unwrap_or(0) / 1000;
85 p::ExportClock { initial: i / 1000, increment: inc, total_time: i / 1000 + 40 * inc }
86 }),
87 clocks: (flag(q, "clocks", false) && row.has_clock())
88 .then(|| row.clock_ms.iter().map(|ms| ms / 10).collect()),
89 }
90}
91
92pub async fn export(
93 State(ctx): State<Ctx>,
94 h: HeaderMap,
95 Path(id): Path<String>,
96 Query(q): Query<HashMap<String, String>>,
97) -> ApiResult<Response> {
98 let c = ctx.db.conn().await?;
99 let row = model::game(&ctx.db, &*c, &id).await?.ok_or_else(|| ApiError::not_found("game"))?;
100 let game =
101 Game::from_moves(row.initial_fen.as_deref(), &row.moves).map_err(|e| anyhow::anyhow!("{e}"))?;
102 let wants_json = h
103 .get(header::ACCEPT)
104 .and_then(|v| v.to_str().ok())
105 .is_some_and(|a| a.contains("application/json") || a.contains("application/x-ndjson"));
106 if wants_json {
107 return Ok(Json(json(&row, &game, &q, &ctx.cfg)).into_response());
108 }
109 let mut r = pgn_text(&row, &game, &ctx.cfg, flag(&q, "clocks", false)).into_response();
110 r.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-chess-pgn"));
111 Ok(r)
112}