Rework library sync around a JSON document; add card filters

Sync

The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.

Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.

Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.

SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.

Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.

Filters

Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.

Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.

The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.

Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
This commit is contained in:
2026-08-07 22:03:49 -05:00
parent 663af32562
commit 04e9c3cbbb
21 changed files with 3370 additions and 100 deletions

View File

@@ -1,15 +1,28 @@
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the db paths.
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths.
//!
//! The user's library is a JSON *document* in a folder they sync. The SQLite
//! database behind it is an internal working copy under the app data dir that
//! they never see, name, or move — it can always be rebuilt by merging the
//! document into an empty one (see [`mtgc_core::sync`]).
use std::path::{Path, PathBuf};
use mtgc_core::{Catalog, NameIndex};
use serde::{Deserialize, Serialize};
/// Small app-shell preferences persisted across restarts (distinct from the
/// user's library data) — currently just which library file was last open.
#[derive(Default, Serialize, Deserialize)]
/// Small app-shell preferences persisted across restarts, distinct from the
/// user's library data.
#[derive(Default, Serialize, Deserialize, Clone)]
struct PersistedConfig {
library_path: Option<PathBuf>,
/// The library document the user chose. Desktop only — Android reaches its
/// folder through a SAF grant held on the Java side rather than by path,
/// so there is no path to persist there (see `sync.rs`).
#[serde(default)]
document_path: Option<PathBuf>,
/// A pre-document `library.db` inherited from an older build, kept until
/// it has been converted so the app can offer to do that once.
#[serde(default)]
legacy_library_path: Option<PathBuf>,
}
fn config_path(data_dir: &Path) -> PathBuf {
@@ -17,10 +30,32 @@ fn config_path(data_dir: &Path) -> PathBuf {
}
fn load_config(data_dir: &Path) -> PersistedConfig {
std::fs::read_to_string(config_path(data_dir))
let text = match std::fs::read_to_string(config_path(data_dir)) {
Ok(t) => t,
Err(_) => return PersistedConfig::default(),
};
if let Ok(cfg) = serde_json::from_str::<PersistedConfig>(&text) {
// Nothing to carry over if it already parses as the current shape,
// unless it's an old config that only had `library_path`.
if cfg.document_path.is_some() || cfg.legacy_library_path.is_some() {
return cfg;
}
}
// Older builds stored `library_path` pointing at a SQLite file the user
// managed themselves. Remember it so the UI can offer to convert it, but
// never open it as the working copy.
#[derive(Deserialize)]
struct OldConfig {
library_path: Option<PathBuf>,
}
let legacy = serde_json::from_str::<OldConfig>(&text)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
.and_then(|c| c.library_path)
.filter(|p| p.extension().is_some_and(|e| e != "json"));
PersistedConfig {
document_path: None,
legacy_library_path: legacy,
}
}
fn save_config(data_dir: &Path, cfg: &PersistedConfig) {
@@ -32,35 +67,41 @@ fn save_config(data_dir: &Path, cfg: &PersistedConfig) {
/// Managed application state, guarded by a `Mutex` in Tauri's state store.
pub struct AppState {
data_dir: PathBuf,
pub library_path: PathBuf,
/// Held whole so that writing one preference can't drop the others — the
/// setters below persist this rather than reconstructing a fresh config.
config: PersistedConfig,
/// Internal SQLite working copy. Derived from the document; never chosen
/// by, shown to, or moved by the user.
pub working_db_path: PathBuf,
pub reference_path: PathBuf,
pub catalog: Option<Catalog>,
pub index: Option<NameIndex>,
/// Set while `update_card_data` is running; `cancel_update_card_data`
/// flips it to abort the in-progress ingest.
pub ingest_cancel: Option<mtgc_core::ingest::CancelToken>,
/// Set while a sync is in flight. Sync fires automatically on launch and on
/// every foreground/background transition, so overlapping calls are easy to
/// provoke; a second one would merge the same document concurrently.
pub sync_running: bool,
}
impl AppState {
/// Initialize with the last-opened library path (or the default under the
/// app data dir, on first run) and open the catalog if a reference db
/// already exists.
pub fn new(data_dir: PathBuf) -> Self {
let cfg = load_config(&data_dir);
let library_path = cfg
.library_path
.unwrap_or_else(|| data_dir.join("library.db"));
let config = load_config(&data_dir);
let mut s = Self {
reference_path: data_dir.join("reference.db"),
library_path,
working_db_path: data_dir.join("working.db"),
config,
data_dir,
catalog: None,
index: None,
ingest_cancel: None,
sync_running: false,
};
crate::mlog!(
"AppState::new: library={} reference={} (exists={})",
s.library_path.display(),
"AppState::new: document={:?} working={} reference={} (exists={})",
s.config.document_path,
s.working_db_path.display(),
s.reference_path.display(),
s.reference_path.exists()
);
@@ -75,16 +116,44 @@ impl AppState {
s
}
/// Point at a different library file and persist it so the next launch
/// reopens the same one.
pub fn set_library_path(&mut self, path: PathBuf) {
self.library_path = path.clone();
save_config(
&self.data_dir,
&PersistedConfig {
library_path: Some(path),
},
);
/// The library document the user explicitly chose, if they have.
pub fn document_path(&self) -> Option<&Path> {
self.config.document_path.as_deref()
}
/// Where the library document actually lives right now.
///
/// Falls back to app-private storage so there is *always* a document. The
/// alternative — no document until the user picks one — meant edits piled
/// up in the working copy with nowhere to be saved, and choosing a
/// destination later would discard them. Picking a location is a
/// preference, not a precondition for using the app.
pub fn effective_document_path(&self) -> PathBuf {
self.config
.document_path
.clone()
.unwrap_or_else(|| self.default_document_path())
}
pub fn default_document_path(&self) -> PathBuf {
self.data_dir.join("library.json")
}
/// Point at (or, with `None`, forget) the library document.
pub fn set_document_path(&mut self, path: Option<PathBuf>) {
self.config.document_path = path;
save_config(&self.data_dir, &self.config);
}
/// A pre-document `library.db` still waiting to be converted.
pub fn legacy_library_path(&self) -> Option<&Path> {
self.config.legacy_library_path.as_deref()
}
/// Forget the legacy library, once it's been converted or dismissed.
pub fn clear_legacy_library(&mut self) {
self.config.legacy_library_path = None;
save_config(&self.data_dir, &self.config);
}
/// Drop the open catalog (and its name index), releasing the handles on both
@@ -100,7 +169,7 @@ impl AppState {
/// db and make every query fail.
pub fn reopen(&mut self) -> Result<(), String> {
if self.reference_path.exists() {
let catalog = Catalog::open(&self.library_path, &self.reference_path)
let catalog = Catalog::open(&self.working_db_path, &self.reference_path)
.map_err(|e| e.to_string())?;
let index = catalog.build_name_index().map_err(|e| e.to_string())?;
self.index = Some(index);