1use axum::Json;
20use axum::extract::{Path, Query, State};
21use axum::response::{IntoResponse, Response};
22use bytes::Bytes;
23use chess_core::Game;
24use serde::Deserialize;
25use serde_json::json;
26
27use crate::app::{Ctx, Who};
28use crate::error::{ApiError, ApiResult};
29use crate::http::{form, ok};
30use crate::{model, vision};
31
32pub fn position_key(game: &Game) -> String {
34 chess_analysis::dictionary::position_key(game)
35}
36
37pub async fn assist(
38 State(ctx): State<Ctx>,
39 who: Who,
40 Path(id): Path<String>,
41 body: Bytes,
42) -> ApiResult<Response> {
43 let me = who.need(&["board:play"])?.player;
44 let f = form(&body);
45 let on = match f.get("on").map(String::as_str) {
46 Some("true" | "1" | "yes") => true,
47 Some("false" | "0" | "no") => false,
48 _ => return Err(ApiError::bad("say on=true or on=false")),
49 };
50 let c = ctx.db.conn().await?;
51 let row = model::game(&ctx.db, &*c, &id).await?.ok_or_else(|| ApiError::not_found("game"))?;
52 if row.white.player_id != me && row.black.player_id != me {
53 return Err(ApiError::forbidden("not your game"));
54 }
55 c.execute(
56 &ctx.db
57 .sql("INSERT INTO chess.assist_marks (game_id, player_id, ply, assist) VALUES ($1, $2, $3, $4)"),
58 &[&id, &me, &row.ply, &on],
59 )
60 .await?;
61 Ok(ok())
62}
63
64#[derive(Deserialize)]
65pub struct PositionQuery {
66 fen: String,
67}
68
69fn wdl_for(game: &Game, cp: i32) -> chess_analysis::Wdl {
71 let white_cp = if game.turn() == chess_core::Side::White { cp } else { -cp };
72 let phase = chess_analysis::eval::phase(game.board());
73 chess_analysis::Wdl::from_cp(white_cp, chess_analysis::wdl::Params::for_phase(phase))
74}
75
76fn parse_fen(fen: &str) -> Result<Game, ApiError> {
77 Game::new(Some(fen.trim())).map_err(|e| ApiError::bad(format!("bad FEN: {e:?}")))
78}
79
80async fn not_playing(ctx: &Ctx, c: &impl tokio_postgres::GenericClient, player: i64) -> ApiResult<()> {
82 if !model::ongoing_games(&ctx.db, c, player).await?.is_empty() {
83 return Err(ApiError::forbidden("position lookups are off while you have a game in progress"));
84 }
85 Ok(())
86}
87
88pub async fn position(
89 State(ctx): State<Ctx>,
90 who: Who,
91 Query(q): Query<PositionQuery>,
92) -> ApiResult<Response> {
93 let game = parse_fen(&q.fen)?;
94 let c = ctx.db.conn().await?;
95 not_playing(&ctx, &*c, who.0.player).await?;
96 let key = position_key(&game);
97 let row = c
98 .query_opt(
99 &ctx.db.sql(
100 "SELECT best, score_cp, depth, engine, seen, photos FROM chess.positions WHERE key = $1",
101 ),
102 &[&key],
103 )
104 .await?;
105 let hint = c
106 .query_opt(
107 &ctx.db.sql(
108 "SELECT best, score_cp, depth, engine FROM chess.position_hints WHERE key = $1
109 ORDER BY depth DESC, at DESC LIMIT 1",
110 ),
111 &[&key],
112 )
113 .await?
114 .map(|h| {
115 let cp: i32 = h.get(1);
116 json!({"best": h.get::<_, Option<String>>(0), "scoreCp": cp, "depth": h.get::<_, i32>(2),
117 "engine": h.get::<_, String>(3), "wdl": wdl_for(&game, cp)})
118 });
119 let Some(r) = row else {
120 return Ok(Json(json!({ "key": key, "known": false, "hint": hint })).into_response());
121 };
122 let photos: i32 = r.get(5);
123 let Some(score) = r.get::<_, Option<i32>>(1) else {
124 return Ok(Json(
126 json!({ "key": key, "known": false, "queued": true, "photos": photos, "hint": hint }),
127 )
128 .into_response());
129 };
130 Ok(Json(json!({
132 "key": key,
133 "known": true,
134 "best": r.get::<_, Option<String>>(0),
135 "scoreCp": score,
136 "depth": r.get::<_, i32>(2),
137 "engine": r.get::<_, Option<String>>(3),
138 "seen": r.get::<_, i32>(4),
139 "photos": photos,
140 "wdl": wdl_for(&game, score),
141 "hint": hint,
142 }))
143 .into_response())
144}
145
146#[derive(Deserialize, serde::Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct HintMove {
150 uci: String,
151 score_cp: i32,
152}
153
154#[derive(Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct HintBody {
157 fen: String,
158 #[serde(default)]
159 best: Option<String>,
160 score_cp: i32,
162 depth: i32,
163 #[serde(default)]
164 engine: Option<String>,
165 #[serde(default)]
167 moves: Vec<HintMove>,
168}
169
170pub async fn position_hint(State(ctx): State<Ctx>, who: Who, Json(b): Json<HintBody>) -> ApiResult<Response> {
175 let me = who.0.player;
176 let game = parse_fen(&b.fen)?;
177 if game.outcome().is_some() {
178 return Err(ApiError::bad("the game is over in that position"));
179 }
180 if !(1..=99).contains(&b.depth) || b.score_cp.abs() > 32_000 || b.moves.len() > 256 {
181 return Err(ApiError::bad("depth is 1–99, scores within ±32000, at most 256 moves"));
182 }
183 let legal = game.legal_moves();
184 if b.best.as_ref().is_some_and(|m| !legal.contains(m)) || b.moves.iter().any(|m| !legal.contains(&m.uci))
185 {
186 return Err(ApiError::bad("a move that isn't legal there"));
187 }
188 let engine: String = b.engine.as_deref().unwrap_or("open-chess app").chars().take(60).collect();
189 let moves =
190 if b.moves.is_empty() { None } else { Some(serde_json::to_value(&b.moves).unwrap_or_default()) };
191 let c = ctx.db.conn().await?;
192 not_playing(&ctx, &*c, me).await?;
193 c.execute(
194 &ctx.db.sql(
195 "INSERT INTO chess.position_hints (key, player_id, best, score_cp, depth, engine, moves)
196 VALUES ($1, $2, $3, $4, $5, $6, $7)
197 ON CONFLICT (key, player_id) DO UPDATE SET best = EXCLUDED.best, score_cp = EXCLUDED.score_cp,
198 depth = EXCLUDED.depth, engine = EXCLUDED.engine, moves = EXCLUDED.moves, at = now()
199 WHERE EXCLUDED.depth >= chess.position_hints.depth",
200 ),
201 &[&position_key(&game), &me, &b.best, &b.score_cp, &b.depth, &engine, &moves],
202 )
203 .await?;
204 Ok(ok())
205}
206
207pub async fn position_moves(
213 State(ctx): State<Ctx>,
214 who: Who,
215 Query(q): Query<PositionQuery>,
216) -> ApiResult<Response> {
217 let game = parse_fen(&q.fen)?;
218 let c = ctx.db.conn().await?;
219 not_playing(&ctx, &*c, who.0.player).await?;
220 let key = position_key(&game);
221 let legal = game.legal_moves();
222 let mut children = Vec::with_capacity(legal.len());
224 for m in &legal {
225 let mut g = game.clone();
226 if g.play_uci(m).is_ok() {
227 children.push((m.clone(), g));
228 }
229 }
230 let child_keys: Vec<String> = children.iter().map(|(_, g)| position_key(g)).collect();
231 let mut known: std::collections::HashMap<String, (i32, i32)> = std::collections::HashMap::new();
232 for r in c
233 .query(
234 &ctx.db.sql("SELECT key, score_cp, depth FROM chess.positions WHERE key = ANY($1) AND score_cp IS NOT NULL"),
235 &[&child_keys],
236 )
237 .await?
238 {
239 known.insert(r.get(0), (r.get(1), r.get(2)));
240 }
241 let moves_of = |row: Option<tokio_postgres::Row>| {
242 row.and_then(|r| r.get::<_, Option<serde_json::Value>>(0))
243 .and_then(|v| v.as_array().cloned())
244 .unwrap_or_default()
245 };
246 let worker = moves_of(
247 c.query_opt(&ctx.db.sql("SELECT moves FROM chess.positions WHERE key = $1"), &[&key]).await?,
248 );
249 let hints = moves_of(
250 c.query_opt(
251 &ctx.db.sql(
252 "SELECT moves FROM chess.position_hints WHERE key = $1 AND moves IS NOT NULL ORDER BY depth DESC LIMIT 1",
253 ),
254 &[&key],
255 )
256 .await?,
257 );
258 let find = |list: &[serde_json::Value], uci: &str, field: &str| {
259 list.iter().find(|m| m["uci"] == uci).and_then(|m| m[field].as_i64()).map(|v| v as i32)
260 };
261 let moves: Vec<serde_json::Value> = children
262 .iter()
263 .zip(&child_keys)
264 .map(|((uci, after), ck)| {
265 let (cp, depth, source) = if let Some(cp) = find(&worker, uci, "cp") {
266 (Some(cp), find(&worker, uci, "depth"), "engine")
267 } else if let Some(&(cp, d)) = known.get(ck) {
268 (Some(-cp), Some(d), "after")
270 } else if let Some(cp) = find(&hints, uci, "scoreCp") {
271 (Some(cp), None, "hint")
272 } else {
273 (None, None, "unknown")
274 };
275 let wdl = cp.map(|v| wdl_for(after, -v));
276 json!({"uci": uci, "san": chess_analysis::explain_san(game.board(), uci).map(|e| e.san), "scoreCp": cp,
277 "depth": depth, "source": source, "wdl": wdl})
278 })
279 .collect();
280 Ok(Json(json!({"key": key, "moves": moves})).into_response())
281}
282
283pub async fn tablebase(
287 State(ctx): State<Ctx>,
288 who: Who,
289 Query(q): Query<PositionQuery>,
290) -> ApiResult<Response> {
291 let game = Game::new(Some(q.fen.trim())).map_err(|e| ApiError::bad(format!("bad FEN: {e:?}")))?;
292 let pieces = game.board().occupied().len();
293 if pieces > 7 {
294 return Err(ApiError::bad("tablebases cover positions with seven pieces or fewer"));
295 }
296 let base = ctx.cfg.tablebase_url.trim().trim_end_matches('/');
297 if base.is_empty() {
298 return Err(ApiError::new(
299 axum::http::StatusCode::SERVICE_UNAVAILABLE,
300 "tablebases are off on this server",
301 ));
302 }
303 let c = ctx.db.conn().await?;
304 if !model::ongoing_games(&ctx.db, &*c, who.0.player).await?.is_empty() {
305 return Err(ApiError::forbidden("tablebase lookups are off while you have a game in progress"));
306 }
307 let key = position_key(&game);
308 if let Some(r) =
309 c.query_opt(&ctx.db.sql("SELECT answer FROM chess.tablebase WHERE key = $1"), &[&key]).await?
310 {
311 let mut v: serde_json::Value = r.get(0);
312 v["cached"] = true.into();
313 return Ok(Json(v).into_response());
314 }
315 let fen = format!("{key} 0 1");
317 let resp = crate::vision::http()
318 .get(format!("{base}/standard"))
319 .query(&[("fen", fen.as_str())])
320 .timeout(std::time::Duration::from_secs(10))
321 .send()
322 .await
323 .map_err(|e| {
324 ApiError::new(axum::http::StatusCode::BAD_GATEWAY, format!("tablebase unreachable: {e}"))
325 })?;
326 if !resp.status().is_success() {
327 return Err(ApiError::new(
328 axum::http::StatusCode::BAD_GATEWAY,
329 format!("tablebase answered {}", resp.status()),
330 ));
331 }
332 let answer: serde_json::Value = resp.json().await.map_err(|e| {
333 ApiError::new(axum::http::StatusCode::BAD_GATEWAY, format!("tablebase answer unreadable: {e}"))
334 })?;
335 c.execute(
336 &ctx.db.sql("INSERT INTO chess.tablebase (key, answer) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING"),
337 &[&key, &answer],
338 )
339 .await?;
340 Ok(Json(answer).into_response())
341}
342
343pub const PHOTO_TERMS: &str = "open-chess-photo-licence-1";
346
347pub(crate) async fn accepted_terms(ctx: &Ctx, player: i64) -> anyhow::Result<Option<bool>> {
349 let c = ctx.db.conn().await?;
350 let r = c
351 .query_opt(
352 &ctx.db.sql("SELECT photo_terms, photo_public FROM chess.players WHERE id = $1"),
353 &[&player],
354 )
355 .await?;
356 Ok(r.and_then(|r| (r.get::<_, Option<String>>(0).as_deref() == Some(PHOTO_TERMS)).then(|| r.get(1))))
357}
358
359#[derive(Debug, Deserialize)]
360pub struct TermsBody {
361 accept: bool,
363 #[serde(default)]
365 public: bool,
366}
367
368fn terms_json(accepted: Option<bool>) -> serde_json::Value {
369 json!({
370 "terms": PHOTO_TERMS,
371 "accepted": accepted.is_some(),
372 "public": accepted.unwrap_or(false),
373 "text": "/docs/photo-licence",
374 })
375}
376
377pub async fn photo_terms(State(ctx): State<Ctx>, who: Who) -> ApiResult<Response> {
379 Ok(Json(terms_json(accepted_terms(&ctx, who.0.player).await?)).into_response())
380}
381
382pub async fn set_photo_terms(
386 State(ctx): State<Ctx>,
387 who: Who,
388 Json(b): Json<TermsBody>,
389) -> ApiResult<Response> {
390 let c = ctx.db.conn().await?;
391 let terms = b.accept.then_some(PHOTO_TERMS);
392 c.execute(
393 &ctx.db.sql(
394 "UPDATE chess.players SET photo_terms = $2, photo_terms_at = now(), photo_public = $3 WHERE id = $1",
395 ),
396 &[&who.0.player, &terms, &(b.accept && b.public)],
397 )
398 .await?;
399 Ok(Json(terms_json(b.accept.then_some(b.public))).into_response())
400}
401
402#[derive(Deserialize)]
403pub struct RecogniseBody {
404 image: String,
406}
407
408pub async fn recognise(
413 State(ctx): State<Ctx>,
414 who: Who,
415 Json(b): Json<RecogniseBody>,
416) -> ApiResult<Response> {
417 let me = who.0.player;
418 if ctx.reader.is_none() && !vision::enabled(&ctx.cfg) {
419 return Err(ApiError::new(
420 axum::http::StatusCode::SERVICE_UNAVAILABLE,
421 "photo reading isn't set up on this server (CHESS_VISION_LOCAL_MODEL or CHESS_VISION_ENDPOINT)",
422 ));
423 }
424 if b.image.len() > 8 * 1024 * 1024 {
425 return Err(ApiError::bad("the photo is too large: send it at 1600 pixels or less"));
426 }
427 let (bytes, mime) = vision::decode_image(&b.image).map_err(|e| ApiError::bad(format!("{e:#}")))?;
428
429 let local = match ctx.reader.clone() {
431 Some(reader) => {
432 let photo = bytes.clone();
433 let started = std::time::Instant::now();
434 let r = tokio::task::spawn_blocking(move || reader.read_encoded(&photo))
435 .await
436 .map_err(|e| ApiError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
437 .map_err(|e| ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, e.to_string()))?;
438 tracing::debug!(
439 ms = started.elapsed().as_millis() as u64,
440 confidence = r.confidence,
441 "photo read"
442 );
443 Some(r)
444 }
445 None => None,
446 };
447 let model_name = ctx.reader.as_ref().map(|r| r.card().name.clone()).filter(|n| !n.is_empty());
448 if let Some(r) = &local
449 && r.is_sure(ctx.cfg.vision_min_confidence)
450 {
451 return Ok(Json(local_answer(r, model_name.as_deref(), None)).into_response());
452 }
453
454 let llm =
456 if vision::enabled(&ctx.cfg) { Some(vision::read_board(&ctx.cfg, &b.image).await) } else { None };
457 let reason = match &local {
458 None => "no_model",
459 Some(r) if !r.plausible => "implausible",
460 Some(_) => "unsure",
461 };
462 let terms = if ctx.cfg.vision_review { accepted_terms(&ctx, me).await? } else { None };
464 let terms_needed = ctx.cfg.vision_review && terms.is_none();
465 let photo_id = match terms {
466 Some(public) => {
467 let r = local.as_ref();
468 keep_photo(&ctx, me, &bytes, &mime, reason, r, model_name.as_deref(), llm.as_ref(), public)
469 .await
470 .unwrap_or_else(|e| {
471 tracing::warn!("keeping a photo for review: {e:#}");
472 None
473 })
474 }
475 None => None,
476 };
477 let llm_answer = |l: &vision::Reading| {
478 let mut v = llm_json(l, &ctx.cfg.vision_model);
479 v["source"] = json!("llm");
480 v["photoId"] = json!(photo_id);
481 v["review"] = json!(photo_id.is_some());
482 v["termsNeeded"] = json!(terms_needed);
483 v
484 };
485 match (local, llm) {
486 (Some(r), Some(Ok(l))) if !r.plausible => Ok(Json(llm_answer(&l)).into_response()),
489 (Some(r), llm) => {
491 let mut v = local_answer(&r, model_name.as_deref(), photo_id);
492 v["termsNeeded"] = json!(terms_needed);
493 if let Some(Ok(l)) = llm {
494 v["llm"] = llm_json(&l, &ctx.cfg.vision_model);
495 }
496 Ok(Json(v).into_response())
497 }
498 (None, Some(Ok(l))) => Ok(Json(llm_answer(&l)).into_response()),
499 (None, Some(Err(e))) => {
500 Err(ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}")))
501 }
502 (None, None) => {
503 Err(ApiError::new(axum::http::StatusCode::UNPROCESSABLE_ENTITY, "the photo couldn't be read"))
504 }
505 }
506}
507
508fn local_answer(
509 r: &chess_vision::BoardReading,
510 model: Option<&str>,
511 photo_id: Option<i64>,
512) -> serde_json::Value {
513 json!({
514 "source": "model",
515 "placement": r.placement,
516 "whiteAtBottom": null,
519 "sideToMove": null,
520 "confidence": r.confidence,
521 "unsureSquares": r.unsure_squares,
522 "plausible": r.plausible,
523 "squares": r.squares,
524 "model": model,
525 "photoId": photo_id,
526 "review": photo_id.is_some(),
527 })
528}
529
530fn llm_json(l: &vision::Reading, model: &str) -> serde_json::Value {
531 json!({
532 "placement": l.placement,
533 "whiteAtBottom": l.white_at_bottom,
534 "sideToMove": l.side_to_move,
535 "confidence": l.confidence,
536 "unsureSquares": l.unsure_squares,
537 "model": model,
538 })
539}
540
541#[allow(clippy::too_many_arguments)]
544async fn keep_photo(
545 ctx: &Ctx,
546 player: i64,
547 bytes: &[u8],
548 mime: &str,
549 reason: &str,
550 local: Option<&chess_vision::BoardReading>,
551 model: Option<&str>,
552 llm: Option<&anyhow::Result<vision::Reading>>,
553 public: bool,
554) -> anyhow::Result<Option<i64>> {
555 let c = ctx.db.conn().await?;
556 let waiting: i64 = c
557 .query_one(
558 &ctx.db.sql("SELECT count(*) FROM chess.board_photos WHERE label IS NULL AND NOT unreadable"),
559 &[],
560 )
561 .await?
562 .get(0);
563 if waiting >= ctx.cfg.vision_review_max {
564 return Ok(None);
565 }
566 let reading = local.map(|r| {
567 json!({"placement": r.placement, "confidence": r.confidence, "unsure_squares": r.unsure_squares,
568 "plausible": r.plausible, "model": model})
569 });
570 let (suggestion, failed) = match llm {
571 Some(Ok(l)) => (
572 Some(json!({"placement": l.placement, "white_at_bottom": l.white_at_bottom,
573 "side_to_move": l.side_to_move, "confidence": l.confidence, "unsure_squares": l.unsure_squares,
574 "model": ctx.cfg.vision_model, "at": chrono::Utc::now()})),
575 None,
576 ),
577 Some(Err(e)) => (None, Some(format!("{e:#}").chars().take(300).collect::<String>())),
578 None => (None, None),
579 };
580 let row = c
581 .query_one(
582 &ctx.db.sql(
583 "INSERT INTO chess.board_photos (player_id, image, mime, reason, reading, suggestion, suggestion_failed, terms, public)
584 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id",
585 ),
586 &[&player, &bytes, &mime, &reason, &reading, &suggestion, &failed, &PHOTO_TERMS, &public],
587 )
588 .await?;
589 Ok(Some(row.get(0)))
590}
591
592#[derive(Deserialize)]
593pub struct ConfirmBody {
594 placement: String,
597 #[serde(default)]
601 fen: Option<String>,
602}
603
604pub async fn queue_photo_positions(
607 db: &crate::db::Db,
608 c: &impl tokio_postgres::GenericClient,
609 games: &[Game],
610) -> anyhow::Result<()> {
611 let sql = db.sql(
612 "INSERT INTO chess.positions (key, score_cp, depth, engine, seen, photos) VALUES ($1, NULL, 0, NULL, 0, 1)
613 ON CONFLICT (key) DO UPDATE SET photos = chess.positions.photos + 1",
614 );
615 for g in games.iter().filter(|g| g.outcome().is_none()) {
616 c.execute(&sql, &[&position_key(g)]).await?;
617 }
618 Ok(())
619}
620
621pub async fn confirm_photo(
626 State(ctx): State<Ctx>,
627 who: Who,
628 Path(id): Path<i64>,
629 Json(b): Json<ConfirmBody>,
630) -> ApiResult<Response> {
631 let grid = chess_analysis::photo::parse_placement(&b.placement)
632 .ok_or_else(|| ApiError::bad("that isn't a board: eight ranks of eight squares"))?;
633 let placement = chess_analysis::photo::placement(&grid);
634 let c = ctx.db.conn().await?;
635 let n = c
636 .execute(
637 &ctx.db.sql(
638 "UPDATE chess.board_photos SET label = $3, label_source = 'player', labelled_by = $2, labelled_at = now()
639 WHERE id = $1 AND player_id = $2 AND NOT unreadable AND coalesce(label_source, 'player') = 'player'",
640 ),
641 &[&id, &who.0.player, &placement],
642 )
643 .await?;
644 if n == 0 {
645 return Err(ApiError::not_found("photo"));
646 }
647 vote(&ctx, &*c, id, who.0.player, Some(&placement), false).await?;
649 let given = b
652 .fen
653 .as_deref()
654 .and_then(|f| Game::new(Some(f.trim())).ok())
655 .filter(|g| g.fen().split_whitespace().next() == Some(placement.as_str()));
656 let games = match given {
657 Some(g) => vec![g],
658 None => chess_analysis::dictionary::photo_positions(&grid, None),
659 };
660 if let Err(e) = queue_photo_positions(&ctx.db, &*c, &games).await {
661 tracing::warn!("queueing a photo's position: {e:#}");
662 }
663 Ok(ok())
664}
665
666fn staff(who: &Who) -> bool {
670 who.0.admin || who.0.manager
671}
672
673async fn vote(
677 ctx: &Ctx,
678 c: &impl tokio_postgres::GenericClient,
679 id: i64,
680 player: i64,
681 placement: Option<&str>,
682 unreadable: bool,
683) -> anyhow::Result<()> {
684 c.execute(
685 &ctx.db.sql(
686 "INSERT INTO chess.photo_votes (photo_id, player_id, placement, unreadable) VALUES ($1, $2, $3, $4)
687 ON CONFLICT (photo_id, player_id) DO UPDATE SET placement = EXCLUDED.placement,
688 unreadable = EXCLUDED.unreadable, voted_at = now()",
689 ),
690 &[&id, &player, &placement, &unreadable],
691 )
692 .await?;
693 let agreed = c
695 .query_opt(
696 &ctx.db.sql(
697 "SELECT placement, unreadable FROM chess.photo_votes WHERE photo_id = $1
698 GROUP BY placement, unreadable HAVING count(*) >= 2 ORDER BY count(*) DESC LIMIT 1",
699 ),
700 &[&id],
701 )
702 .await?;
703 if let Some(r) = agreed {
704 let (placement, unreadable): (Option<String>, bool) = (r.get(0), r.get(1));
705 c.execute(
706 &ctx.db.sql(
707 "UPDATE chess.board_photos SET label = $2, unreadable = $3, label_source = CASE WHEN $3 THEN NULL ELSE 'consensus' END,
708 labelled_by = NULL, labelled_at = now()
709 WHERE id = $1 AND coalesce(label_source, '') NOT IN ('admin', 'reviewer')
710 AND NOT (unreadable AND labelled_by IS NOT NULL)",
711 ),
712 &[&id, &placement, &unreadable],
713 )
714 .await?;
715 }
716 Ok(())
717}
718
719#[derive(Debug, Deserialize)]
720pub struct PhotoList {
721 #[serde(default)]
722 all: bool,
723}
724
725pub async fn photos(State(ctx): State<Ctx>, who: Who, Query(q): Query<PhotoList>) -> ApiResult<Response> {
731 let me = who.0.player;
732 let staff = staff(&who);
733 let all = q.all && staff;
734 let c = ctx.db.conn().await?;
735 let rows = c
736 .query(
737 &ctx.db.sql(
738 "SELECT p.id, p.created_at, p.reason, p.reading, p.suggestion, p.suggestion_failed, p.label, p.label_source,
739 p.unreadable, p.labelled_at, p.public, p.player_id = $2,
740 (SELECT count(*) FROM chess.photo_votes v WHERE v.photo_id = p.id),
741 (SELECT row_to_json(v) FROM (SELECT placement, unreadable FROM chess.photo_votes
742 WHERE photo_id = p.id AND player_id = $2) v)
743 FROM chess.board_photos p
744 WHERE $1 OR (
745 coalesce(p.label_source, 'player') = 'player' AND NOT p.unreadable
746 AND ($3 OR (p.terms IS NOT NULL AND coalesce(p.player_id, 0) <> $2
747 AND NOT EXISTS (SELECT 1 FROM chess.photo_votes v WHERE v.photo_id = p.id AND v.player_id = $2))))
748 ORDER BY CASE WHEN $3 THEN (SELECT count(*) FROM chess.photo_votes v WHERE v.photo_id = p.id) ELSE 0 END DESC, p.id DESC
749 LIMIT 200",
750 ),
751 &[&all, &me, &staff],
752 )
753 .await?;
754 let list: Vec<serde_json::Value> = rows
755 .iter()
756 .map(|r| {
757 json!({
758 "id": r.get::<_, i64>(0),
759 "at": r.get::<_, chrono::DateTime<chrono::Utc>>(1),
760 "reason": r.get::<_, String>(2),
761 "reading": r.get::<_, Option<serde_json::Value>>(3),
762 "suggestion": r.get::<_, Option<serde_json::Value>>(4),
763 "suggestion_failed": r.get::<_, Option<String>>(5),
764 "label": r.get::<_, Option<String>>(6),
765 "label_source": r.get::<_, Option<String>>(7),
766 "unreadable": r.get::<_, bool>(8),
767 "labelled_at": r.get::<_, Option<chrono::DateTime<chrono::Utc>>>(9),
768 "public": r.get::<_, bool>(10),
769 "mine": r.get::<_, Option<bool>>(11).unwrap_or(false),
770 "votes": r.get::<_, i64>(12),
771 "my_vote": r.get::<_, Option<serde_json::Value>>(13),
772 "disputed": r.get::<_, i64>(12) >= 3 && r.get::<_, Option<String>>(7).as_deref() != Some("consensus"),
774 })
775 })
776 .collect();
777 Ok(Json(json!({ "photos": list, "staff": staff, "terms": PHOTO_TERMS })).into_response())
778}
779
780pub async fn photo_image(State(ctx): State<Ctx>, _who: Who, Path(id): Path<i64>) -> ApiResult<Response> {
782 let c = ctx.db.conn().await?;
783 let r = c
784 .query_opt(&ctx.db.sql("SELECT image, mime FROM chess.board_photos WHERE id = $1"), &[&id])
785 .await?
786 .ok_or_else(|| ApiError::not_found("photo"))?;
787 let (bytes, mime): (Vec<u8>, String) = (r.get(0), r.get(1));
788 Ok((
789 [
790 (axum::http::header::CONTENT_TYPE, mime),
791 (axum::http::header::CACHE_CONTROL, "private, max-age=3600".into()),
792 ],
793 bytes,
794 )
795 .into_response())
796}
797
798#[derive(Debug, Deserialize)]
799pub struct PhotoLabel {
800 #[serde(default)]
802 placement: Option<String>,
803 #[serde(default)]
805 unreadable: bool,
806}
807
808pub async fn label_photo(
814 State(ctx): State<Ctx>,
815 who: Who,
816 Path(id): Path<i64>,
817 Json(b): Json<PhotoLabel>,
818) -> ApiResult<Response> {
819 let player = who.0.player;
820 let grid = match (&b.placement, b.unreadable) {
821 (_, true) => None,
822 (Some(p), false) => Some(
823 chess_analysis::photo::parse_placement(p.split_whitespace().next().unwrap_or(""))
824 .ok_or_else(|| ApiError::bad("that isn't a board: eight ranks of eight squares"))?,
825 ),
826 (None, false) => return Err(ApiError::bad("say what the board shows, or mark it unreadable")),
827 };
828 let placement = grid.as_ref().map(chess_analysis::photo::placement);
829 let c = ctx.db.conn().await?;
830 let exists = c.query_opt(&ctx.db.sql("SELECT 1 FROM chess.board_photos WHERE id = $1"), &[&id]).await?;
831 if exists.is_none() {
832 return Err(ApiError::not_found("photo"));
833 }
834 vote(&ctx, &*c, id, player, placement.as_deref(), b.unreadable).await?;
835 if staff(&who) {
836 c.execute(
837 &ctx.db.sql(
838 "UPDATE chess.board_photos SET label = $2, unreadable = $3, labelled_by = $4, labelled_at = now(),
839 label_source = CASE WHEN $3 THEN NULL ELSE 'admin' END
840 WHERE id = $1",
841 ),
842 &[&id, &placement, &b.unreadable, &player],
843 )
844 .await?;
845 }
846 let row = c
847 .query_one(
848 &ctx.db.sql("SELECT label, label_source, unreadable FROM chess.board_photos WHERE id = $1"),
849 &[&id],
850 )
851 .await?;
852 let settled =
853 matches!(row.get::<_, Option<String>>(1).as_deref(), Some("consensus" | "admin" | "reviewer"))
854 || row.get::<_, bool>(2);
855 if let (Some(grid), true) = (&grid, settled && row.get::<_, Option<String>>(0) == placement) {
856 let games = chess_analysis::dictionary::photo_positions(grid, None);
857 if let Err(e) = queue_photo_positions(&ctx.db, &*c, &games).await {
858 tracing::warn!("queueing a photo's position: {e:#}");
859 }
860 }
861 Ok(Json(json!({ "ok": true, "settled": settled, "label": row.get::<_, Option<String>>(0) }))
862 .into_response())
863}