Files
mtgc/src-tauri/src/commands.rs
Garrett Mills 81b1df8c9f Named libraries: switch between several, each syncing on its own
The app can keep any number of named libraries and switch between them
from the top bar or Info → Libraries. Each can sync to the account's other
devices or stay on this one (a scratch library to try things in).

The sync server now keeps several libraries per user under
/api/libraries; a migration makes each existing library the user's default
"Main", which /api/library keeps serving for older app builds. Every sync
lines the device's libraries up with the account's: new ones elsewhere are
downloaded, new ones here created on the server, ones deleted elsewhere
flagged rather than thrown away. The existing library keeps its files and
merge base, so upgrading asks nothing.
2026-10-16 21:44:55 -05:00

444 lines
14 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, CardSuggestion, Deck, DeckEntry, LibraryCard, LibraryCopy, LibraryFacets,
LibraryFilter, LibraryStats, PrintingRef, SuggestOptions,
};
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 {
/// The open library's name and where it syncs: the account and server, or
/// this device only. Changes whenever another library is opened.
pub library_label: String,
/// The open library.
pub library: OpenLibrary,
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>,
/// Columns this build's card-data schema has that the installed card data
/// lacks (`"table.column"`). Non-empty means it was built by an older
/// version and should be updated; empty when current or not loaded.
pub reference_missing_columns: Vec<String>,
/// Whether this is a mobile build. The UI uses it to hide affordances that
/// only make sense with an arbitrary-path filesystem. Reported from the build
/// target rather than sniffed from the user agent so it can't disagree with
/// what the backend will actually allow.
pub is_mobile: bool,
}
#[derive(Serialize)]
pub struct OpenLibrary {
pub id: String,
pub name: String,
}
/// The open library and where it syncs, for the top bar — never the internal
/// working database, which is an implementation detail the user never sees.
fn library_label(s: &AppState) -> String {
let lib = s.active_library();
match s.sync_account() {
Some(a) if a.token.is_some() && lib.sync => {
let host = url::Url::parse(&a.server_url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.unwrap_or_else(|| a.server_url.clone());
format!("{} · {} @ {host}", lib.name, a.user.username)
}
_ => format!("{} · this device only", lib.name),
}
}
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);
let lib = s.active_library();
Status {
library_label: library_label(s),
library: OpenLibrary { id: lib.id.clone(), name: lib.name.clone() },
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,
reference_missing_columns: s
.catalog
.as_ref()
.map(|c| c.missing_reference_columns().to_vec())
.unwrap_or_default(),
is_mobile: cfg!(mobile),
}
}
#[tauri::command]
pub fn get_status(db: Db) -> Res<Status> {
let status = status_of(&db.lock().unwrap());
// Only ever called once per launch (from the frontend's one status-fetch
// effect, retried on failure/timeout) — cheap to log unconditionally, and
// it's the one way to tell "this invoke never reached Rust" (nothing
// logged at all) apart from "it reached Rust and returned not-ready".
crate::mlog!(
"get_status: reference_ready={} card_count={}",
status.reference_ready,
status.card_count
);
Ok(status)
}
/// 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();
crate::mlog!("update_card_data: starting ingest into {}", reference_path.display());
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;
let stats = crate::logged("update_card_data", result.map_err(|e| e.to_string()))?;
crate::mlog!(
"update_card_data: ok ({} oracle cards, {} printings)",
stats.oracle_cards,
stats.printings
);
let mut s = db.lock().unwrap();
crate::logged("update_card_data reopen", s.reopen())?;
Ok(status_of(&s))
}
/// Adopt an already-built `reference.db` from elsewhere on the device instead of
/// downloading and rebuilding it.
///
/// This is the practical way to provision card data on mobile: the built db is
/// ~73MB, whereas `update_card_data` streams ~740MB of JSON and can take many
/// minutes — and Android may kill the app partway through. Copy the file from a
/// desktop install and point this at it.
#[tauri::command]
pub fn import_reference_db(db: Db, path: String) -> Res<Status> {
let src = PathBuf::from(&path);
let mut s = db.lock().unwrap();
let dest = s.reference_path.clone();
if src == dest {
return Err("That file is already the active card database.".into());
}
crate::mlog!("import_reference_db: from {}", src.display());
// Validate before disturbing the working db, so picking the wrong file is a
// harmless error rather than a broken install.
let cards = crate::logged(
"import_reference_db validate",
mtgc_core::db::inspect_reference_db(&src).map_err(|e| e.to_string()),
)?;
crate::mlog!("import_reference_db: source holds {cards} oracle cards");
// Release the handles on the file we're about to replace.
s.close();
// Copy beside the destination, then rename: a same-directory rename is
// atomic, so an interrupted copy can't leave a half-written reference.db.
let tmp = dest.with_extension("db.import");
let install = || -> std::io::Result<()> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src, &tmp)?;
std::fs::rename(&tmp, &dest)
};
if let Err(e) = install() {
let _ = std::fs::remove_file(&tmp);
let _ = s.reopen(); // put the previous db back into service
return Err(format!("Couldn't install that card database: {e}"));
}
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 suggest_cards(
db: Db,
deck_id: i64,
offset: i64,
options: SuggestOptions,
) -> Res<Vec<CardSuggestion>> {
let s = db.lock().unwrap();
s.catalog()?
.suggest_cards(deck_id, 50, offset.max(0) as usize, options)
.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 search_reference(db: Db, filter: LibraryFilter, limit: usize) -> Res<Vec<LibraryCard>> {
let s = db.lock().unwrap();
s.catalog()?
.search_reference(&filter, limit)
.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 get_reference_facets(db: Db) -> Res<LibraryFacets> {
let s = db.lock().unwrap();
s.catalog()?.reference_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 list_printings(db: Db, oracle_id: String) -> Res<Vec<PrintingRef>> {
let s = db.lock().unwrap();
s.catalog()?.list_printings(&oracle_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_library_copies(db: Db, oracle_id: String) -> Res<Vec<LibraryCopy>> {
let s = db.lock().unwrap();
s.catalog()?
.list_library_copies(&oracle_id)
.map_err(|e| e.to_string())
}
/// Returns the card's owned quantity afterwards, which grows when the copy
/// groups would otherwise exceed it.
#[tauri::command]
pub fn set_library_copy(
db: Db,
oracle_id: String,
scryfall_id: String,
finish: String,
quantity: i64,
) -> Res<i64> {
let s = db.lock().unwrap();
s.catalog()?
.set_library_copy(&oracle_id, &scryfall_id, &finish, quantity)
.map_err(|e| e.to_string())
}
/// Returns the slot afterwards, so the UI can show the resulting art without
/// knowing how the default is chosen.
#[tauri::command]
pub fn set_deck_card_printing(
db: Db,
deck_id: i64,
oracle_id: String,
scryfall_id: Option<String>,
) -> Res<Option<DeckEntry>> {
let s = db.lock().unwrap();
let catalog = s.catalog()?;
catalog
.set_deck_card_printing(deck_id, &oracle_id, scryfall_id.as_deref())
.map_err(|e| e.to_string())?;
let contents = catalog.deck_contents(deck_id).map_err(|e| e.to_string())?;
Ok(contents.into_iter().find(|e| e.card.oracle_id == oracle_id))
}
#[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 })
}