Threads Scryfall's per-format legalities through the reference db and adds a deck-page indicator (structural rules + banned/restricted lists) that lists violation reasons inline. Decks and selected library cards can now be exported to/imported from plain-text decklists, with best-effort name resolution and a report of anything that didn't match. Also adds a shuffle- and-draw sample hand modal, and an Alt+/ command palette for jumping to a tab, deck, or card without leaving the keyboard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
268 lines
8.2 KiB
Rust
268 lines
8.2 KiB
Rust
//! Tauri commands: the typed IPC surface bridging the web UI to `mtgc-core`.
|
|
//!
|
|
//! JS passes camelCase argument keys; Tauri maps them to these snake_case params.
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::Mutex;
|
|
|
|
use mtgc_core::{CardRef, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, LibraryStats};
|
|
use serde::Serialize;
|
|
use tauri::{AppHandle, Emitter, State};
|
|
|
|
use crate::state::AppState;
|
|
|
|
type Db<'a> = State<'a, Mutex<AppState>>;
|
|
type Res<T> = Result<T, String>;
|
|
|
|
/// High-level status for the top bar / first-run gating.
|
|
#[derive(Serialize)]
|
|
pub struct Status {
|
|
pub library_path: String,
|
|
pub reference_path: String,
|
|
pub reference_ready: bool,
|
|
pub card_count: usize,
|
|
/// Unix timestamp (seconds) of reference.db's last modification — i.e.
|
|
/// when "Update card data" last completed. `None` if unavailable.
|
|
pub reference_updated_at: Option<i64>,
|
|
}
|
|
|
|
fn status_of(s: &AppState) -> Status {
|
|
let reference_updated_at = std::fs::metadata(&s.reference_path)
|
|
.and_then(|m| m.modified())
|
|
.ok()
|
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|d| d.as_secs() as i64);
|
|
Status {
|
|
library_path: s.library_path.display().to_string(),
|
|
reference_path: s.reference_path.display().to_string(),
|
|
reference_ready: s.catalog.is_some(),
|
|
card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0),
|
|
reference_updated_at,
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_status(db: Db) -> Res<Status> {
|
|
Ok(status_of(&db.lock().unwrap()))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_library_path(db: Db, path: String) -> Res<Status> {
|
|
let mut s = db.lock().unwrap();
|
|
s.set_library_path(PathBuf::from(path));
|
|
// Best-effort reopen: the chosen path must stick (and be reported back)
|
|
// even if the reference db can't currently be attached (e.g. stale
|
|
// schema pending an "Update card data" run) — that's a separate concern
|
|
// from which library file is open.
|
|
let _ = s.reopen();
|
|
Ok(status_of(&s))
|
|
}
|
|
|
|
/// Download Scryfall bulk data and (re)build reference.db, then reopen.
|
|
/// Runs on a blocking thread so the UI stays responsive and progress events flow.
|
|
#[tauri::command]
|
|
pub async fn update_card_data(app: AppHandle, db: Db<'_>) -> Res<Status> {
|
|
let reference_path = db.lock().unwrap().reference_path.clone();
|
|
let cancel = mtgc_core::ingest::CancelToken::new();
|
|
db.lock().unwrap().ingest_cancel = Some(cancel.clone());
|
|
let app_for_progress = app.clone();
|
|
|
|
let result = tauri::async_runtime::spawn_blocking({
|
|
let cancel = cancel.clone();
|
|
move || {
|
|
mtgc_core::ingest::build_reference_db(&reference_path, &cancel, |p| {
|
|
let _ = app_for_progress.emit("ingest-progress", p);
|
|
})
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
db.lock().unwrap().ingest_cancel = None;
|
|
result.map_err(|e| e.to_string())?;
|
|
|
|
let mut s = db.lock().unwrap();
|
|
s.reopen()?;
|
|
Ok(status_of(&s))
|
|
}
|
|
|
|
/// Cancel an in-progress `update_card_data`, if one is running. No-op otherwise.
|
|
#[tauri::command]
|
|
pub fn cancel_update_card_data(db: Db) -> Res<()> {
|
|
let s = db.lock().unwrap();
|
|
if let Some(c) = &s.ingest_cancel {
|
|
c.cancel();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn search_cards(db: Db, query: String, limit: usize) -> Res<Vec<CardRef>> {
|
|
let s = db.lock().unwrap();
|
|
Ok(s.index()?.search(&query, limit))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn add_owned(db: Db, oracle_id: String, delta: i64) -> Res<i64> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?.add_owned(&oracle_id, delta).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_owned(db: Db, oracle_id: String, quantity: i64) -> Res<i64> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?
|
|
.set_owned(&oracle_id, quantity)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_location(db: Db, oracle_id: String, location: String) -> Res<()> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?
|
|
.set_location(&oracle_id, &location)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn deck_contents(db: Db, deck_id: i64) -> Res<Vec<DeckEntry>> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?.deck_contents(deck_id).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn search_library(db: Db, filter: LibraryFilter) -> Res<Vec<LibraryCard>> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?.search_library(&filter).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_library_stats(db: Db) -> Res<LibraryStats> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?.library_stats().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn get_library_facets(db: Db) -> Res<LibraryFacets> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?.library_facets().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn list_decks(db: Db) -> Res<Vec<Deck>> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?.list_decks().map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn create_deck(db: Db, name: String, commander_oracle_id: Option<String>) -> Res<Deck> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?
|
|
.create_deck(&name, commander_oracle_id.as_deref())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn delete_deck(db: Db, deck_id: i64) -> Res<()> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?.delete_deck(deck_id).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn rename_deck(db: Db, deck_id: i64, name: String) -> Res<()> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?
|
|
.rename_deck(deck_id, &name)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn duplicate_deck(db: Db, deck_id: i64, new_name: String) -> Res<Deck> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?
|
|
.duplicate_deck(deck_id, &new_name)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_deck_format(db: Db, deck_id: i64, format: String) -> Res<()> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?
|
|
.set_deck_format(deck_id, &format)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_deck_commander(db: Db, deck_id: i64, oracle_id: Option<String>) -> Res<()> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?
|
|
.set_deck_commander(deck_id, oracle_id.as_deref())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn set_deck_card(
|
|
db: Db,
|
|
deck_id: i64,
|
|
oracle_id: String,
|
|
quantity: i64,
|
|
is_commander: bool,
|
|
is_maybeboard: bool,
|
|
) -> Res<()> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?
|
|
.set_deck_card(deck_id, &oracle_id, quantity, is_commander, is_maybeboard)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn card_decks(db: Db, oracle_id: String) -> Res<Vec<Deck>> {
|
|
let s = db.lock().unwrap();
|
|
s.catalog()?.card_decks(&oracle_id).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct DeckImportResult {
|
|
pub deck: Deck,
|
|
pub unresolved: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct LibraryImportResult {
|
|
pub added: Vec<(String, i64)>,
|
|
pub unresolved: Vec<String>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn export_deck(db: Db, deck_id: i64, path: String) -> Res<()> {
|
|
let s = db.lock().unwrap();
|
|
let text = s.catalog()?.export_deck_text(deck_id).map_err(|e| e.to_string())?;
|
|
std::fs::write(&path, text).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn import_deck(db: Db, path: String, name: String) -> Res<DeckImportResult> {
|
|
let s = db.lock().unwrap();
|
|
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
|
let (deck, unresolved) = s.catalog()?.import_deck(&name, &text).map_err(|e| e.to_string())?;
|
|
Ok(DeckImportResult { deck, unresolved })
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn export_library_cards(db: Db, oracle_ids: Vec<String>, path: String) -> Res<()> {
|
|
let s = db.lock().unwrap();
|
|
let text = s
|
|
.catalog()?
|
|
.export_library_text(&oracle_ids)
|
|
.map_err(|e| e.to_string())?;
|
|
std::fs::write(&path, text).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn import_library_cards(db: Db, path: String) -> Res<LibraryImportResult> {
|
|
let s = db.lock().unwrap();
|
|
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
|
let (added, unresolved) = s.catalog()?.import_library_list(&text).map_err(|e| e.to_string())?;
|
|
Ok(LibraryImportResult { added, unresolved })
|
|
}
|