Windows refuses to replace a file SQLite has open, so every update after the first failed at the final swap with "Access is denied". Building and installing are now separate: the app stays usable during the build, then closes the catalog, swaps the file and reopens under the state lock. The swap retries briefly in case a virus scanner is holding the new file, and a failed one puts the previous data back in service and removes the temp file. Importing a card database shares the same swap.
614 lines
21 KiB
Rust
614 lines
21 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::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<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 (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<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());
|
|
|
|
// 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<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())
|
|
}
|
|
|
|
/// Every deck's contents keyed by deck id, for the deck list's summaries.
|
|
#[tauri::command]
|
|
pub fn all_deck_contents(db: Db) -> Res<HashMap<i64, Vec<DeckEntry>>> {
|
|
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<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_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<i64> {
|
|
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<String>,
|
|
add: Vec<String>,
|
|
remove: Vec<String>,
|
|
) -> 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<bool> {
|
|
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<String>) -> 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<String>) -> 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<String>) -> 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<Vec<CardRef>> {
|
|
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<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(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<String> {
|
|
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<Vec<u8>> {
|
|
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<Option<String>> {
|
|
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<DeckImportResult> {
|
|
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<String>,
|
|
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<String>,
|
|
) -> Res<LibraryImportResult> {
|
|
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
|
|
}
|