Files
mtgc/src-tauri/src/commands.rs
Garrett Mills 507df81f5f ManaBox import, with progress and a format explainer
Library → Import cards… now reads a ManaBox collection export, either the
.csv or the .txt. The CSV is matched by Scryfall ID, falling back to name,
set and collector number, and its Foil column picks the copy group's finish.
Condition, language and prices are ignored; proxies count as owned.

A decklist line that names a printing now resolves to that printing's card
when several cards share the name (a card and its token, or an art-series
entry), and a bare name prefers the real card.

Imports run in one transaction (nestable via SAVEPOINT), which took the
942-row sample from 22s to 0.2s, and off the main thread, with a progress
dialog for slow ones. An explainer of the supported formats now comes
before the file picker.
2026-10-16 21:57:52 -05:00

468 lines
15 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())
}
/// How far a library import has got, for the progress bar.
#[derive(Clone, Serialize)]
pub struct LibraryImportProgress {
pub done: usize,
pub total: usize,
}
/// Import a card list (plain text or a ManaBox export) into the library.
/// Runs on a blocking thread so the UI stays responsive, emitting
/// `library-import-progress` as it goes.
#[tauri::command]
pub async fn import_library_cards(app: AppHandle, path: String) -> Res<LibraryImportResult> {
crate::sync::blocking(move || {
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let s = crate::sync::lock(&app);
// At most one event per percent, so a big file doesn't flood the UI.
let mut last_percent = None;
let (added, unresolved) = s
.catalog()?
.import_card_list(&text, |done, total| {
let percent = (done * 100).checked_div(total).unwrap_or(100);
if last_percent != Some(percent) {
last_percent = Some(percent);
let _ = app.emit("library-import-progress", LibraryImportProgress { done, total });
}
})
.map_err(|e| e.to_string())?;
Ok(LibraryImportResult { added, unresolved })
})
.await
}