//! 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::collections::HashMap; use std::path::PathBuf; use std::sync::Mutex; use mtgc_core::{ CardRef, CardSuggestion, Deck, DeckEntry, DeckStatus, LibraryCard, LibraryCopy, LibraryFacets, LibraryFilter, LibraryStats, PrintingRef, SuggestOptions, }; use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use tauri_plugin_fs::{FilePath, FsExt}; use crate::state::AppState; type Db<'a> = State<'a, Mutex>; type Res = Result; /// 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, /// 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, /// 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 { 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 { 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 (built, stats) = crate::logged("update_card_data", result.map_err(|e| e.to_string()))?; crate::mlog!( "update_card_data: built ({} oracle cards, {} printings)", stats.oracle_cards, stats.printings ); let mut s = db.lock().unwrap(); crate::logged("update_card_data install", s.replace_reference(&built))?; 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(app: AppHandle, db: Db, path: String) -> Res { 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()); // Copy beside the destination, then rename: a same-directory rename is // atomic, so an interrupted copy can't leave a half-written reference.db. // The copy also gives SQLite a real file, which an Android pick (a // content:// URI) isn't. let tmp = dest.with_extension("db.import"); if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } if let Err(e) = copy_picked(&app, &path, &tmp) { let _ = std::fs::remove_file(&tmp); return Err(format!("Couldn't read that card database: {e}")); } // 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(&tmp).map_err(|e| e.to_string()), ) .inspect_err(|_| { let _ = std::fs::remove_file(&tmp); })?; crate::mlog!("import_reference_db: source holds {cards} oracle cards"); s.replace_reference(&tmp)?; 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> { 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 { 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 { 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> { let s = db.lock().unwrap(); s.catalog()?.deck_contents(deck_id).map_err(|e| e.to_string()) } /// Every deck's contents keyed by deck id, for the deck list's summaries. #[tauri::command] pub fn all_deck_contents(db: Db) -> Res>> { let s = db.lock().unwrap(); s.catalog()?.all_deck_contents().map_err(|e| e.to_string()) } #[tauri::command] pub fn suggest_cards( db: Db, deck_id: i64, offset: i64, options: SuggestOptions, ) -> Res> { 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> { 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> { 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 { 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 { 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 { let s = db.lock().unwrap(); s.catalog()?.reference_facets().map_err(|e| e.to_string()) } #[tauri::command] pub fn list_decks(db: Db) -> Res> { 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) -> Res { 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 { 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_status(db: Db, deck_id: i64, status: DeckStatus, assembled: bool) -> Res<()> { let s = db.lock().unwrap(); s.catalog()? .set_deck_status(deck_id, status, assembled) .map_err(|e| e.to_string()) } #[tauri::command] pub fn set_deck_card_pulled(db: Db, deck_id: i64, oracle_id: String, pulled: i64) -> Res { let s = db.lock().unwrap(); s.catalog()? .set_deck_card_pulled(deck_id, &oracle_id, pulled) .map_err(|e| e.to_string()) } /// Add and remove tags on one or more of a deck's cards. #[tauri::command] pub fn set_deck_card_tags( db: Db, deck_id: i64, oracle_ids: Vec, add: Vec, remove: Vec, ) -> Res<()> { fn refs(v: &[String]) -> Vec<&str> { v.iter().map(String::as_str).collect() } let s = db.lock().unwrap(); s.catalog()? .set_deck_card_tags(deck_id, &refs(&oracle_ids), &refs(&add), &refs(&remove)) .map_err(|e| e.to_string()) } #[tauri::command] pub fn take_deck_card_copy(db: Db, from_deck_id: i64, to_deck_id: i64, oracle_id: String) -> Res { let s = db.lock().unwrap(); s.catalog()? .take_deck_card_copy(from_deck_id, to_deck_id, &oracle_id) .map_err(|e| e.to_string()) } #[tauri::command] pub fn set_deck_cover(db: Db, deck_id: i64, oracle_id: Option) -> Res<()> { let s = db.lock().unwrap(); s.catalog()? .set_deck_cover(deck_id, oracle_id.as_deref()) .map_err(|e| e.to_string()) } #[tauri::command] pub fn set_deck_commander(db: Db, deck_id: i64, oracle_id: Option) -> Res<()> { let s = db.lock().unwrap(); s.catalog()? .set_deck_commander(deck_id, oracle_id.as_deref()) .map_err(|e| e.to_string()) } /// Set the deck's commanders — one, or a partner pair — first is primary. #[tauri::command] pub fn set_deck_commanders(db: Db, deck_id: i64, oracle_ids: Vec) -> Res<()> { let s = db.lock().unwrap(); let ids: Vec<&str> = oracle_ids.iter().map(String::as_str).collect(); s.catalog()? .set_deck_commanders(deck_id, &ids) .map_err(|e| e.to_string()) } /// Name search narrowed to cards that could partner with the given commander. /// Searches wide before filtering, since most name matches won't qualify. #[tauri::command] pub fn search_partners( db: Db, commander_oracle_id: String, query: String, limit: usize, ) -> Res> { let s = db.lock().unwrap(); let candidates = s.index()?.search(&query, 500); s.catalog()? .partner_candidates(&commander_oracle_id, candidates, limit) .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> { 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> { 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 { 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, ) -> Res> { 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> { 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, } #[derive(Serialize)] pub struct LibraryImportResult { pub added: Vec<(String, i64)>, pub unresolved: Vec, } #[tauri::command] pub fn export_deck(app: AppHandle, 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())?; write_picked_text(&app, &path, &text) } /// Write a text file the save dialog picked. On Android that's a content:// URI /// for a document the system has already created (empty), so std::fs can't /// write it; the fs plugin opens it through the content resolver. pub(crate) fn write_picked_text(app: &AppHandle, path: &str, text: &str) -> Res<()> { use std::io::Write; let path: FilePath = path.parse().unwrap(); let mut opts = tauri_plugin_fs::OpenOptions::new(); opts.write(true).create(true).truncate(true); let mut file = app.fs().open(path, opts).map_err(|e| e.to_string())?; file.write_all(text.as_bytes()).map_err(|e| e.to_string()) } /// Read a text file the open dialog picked. On Android that's a content:// URI /// rather than a filesystem path, so it has to go through the fs plugin. fn read_picked_text(app: &AppHandle, path: &str) -> Res { let path: FilePath = path.parse().unwrap(); app.fs().read_to_string(path).map_err(|e| e.to_string()) } /// Read a file the open dialog picked, as bytes; see [`read_picked_text`]. pub(crate) fn read_picked(app: &AppHandle, path: &str) -> Res> { let path: FilePath = path.parse().unwrap(); app.fs().read(path).map_err(|e| e.to_string()) } /// Copy a file the open dialog picked to `dest`, streaming it rather than /// holding it in memory. For files SQLite has to open, which on Android can't /// be done through the content:// URI the dialog returns. pub(crate) fn copy_picked(app: &AppHandle, path: &str, dest: &std::path::Path) -> Res<()> { let path: FilePath = path.parse().unwrap(); let mut opts = tauri_plugin_fs::OpenOptions::new(); opts.read(true); let mut src = app.fs().open(path, opts).map_err(|e| e.to_string())?; let mut out = std::fs::File::create(dest).map_err(|e| e.to_string())?; std::io::copy(&mut src, &mut out).map_err(|e| e.to_string())?; out.sync_all().map_err(|e| e.to_string()) } /// The name to show for a file the open dialog picked: its last path segment /// on desktop, the document's display name on Android (whose content:// URIs /// often end in an opaque id like `msf:1000012345`). #[tauri::command] pub async fn picked_file_name(app: AppHandle, path: String) -> Res> { crate::sync::blocking(move || { #[cfg(target_os = "android")] if path.starts_with("content://") { return Ok(crate::share::display_name(&app, &path)); } let _ = app; Ok(std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned())) }) .await } #[tauri::command] pub fn import_deck(app: AppHandle, db: Db, path: String, name: String) -> Res { let text = read_picked_text(&app, &path)?; let s = db.lock().unwrap(); 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( app: AppHandle, db: Db, oracle_ids: Vec, path: String, ) -> Res<()> { let s = db.lock().unwrap(); let text = s .catalog()? .export_library_text(&oracle_ids) .map_err(|e| e.to_string())?; write_picked_text(&app, &path, &text) } /// 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, /// filing every imported card under `location` unless it's blank. /// 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, location: Option, ) -> Res { crate::sync::blocking(move || { let text = read_picked_text(&app, &path)?; 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, location.as_deref().unwrap_or(""), |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 }