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:
@@ -17,6 +17,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[
|
||||
include_str!("sql/library/0002_location.sql"),
|
||||
include_str!("sql/library/0003_deck_format.sql"),
|
||||
include_str!("sql/library/0004_deck_maybeboard.sql"),
|
||||
include_str!("sql/library/0005_deck_uuid.sql"),
|
||||
];
|
||||
|
||||
/// The attached alias under which the reference db is available at runtime.
|
||||
@@ -114,6 +115,19 @@ pub fn inspect_reference_db(path: &Path) -> Result<i64> {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Open (and migrate) just the library db, with no reference db attached.
|
||||
///
|
||||
/// Sync needs this: it only ever touches the user's own tables, and must work
|
||||
/// on a device that hasn't downloaded card data yet — where [`open`] can't be
|
||||
/// used at all, since ATTACHing a non-existent reference file would create an
|
||||
/// empty one and make every later query fail.
|
||||
pub fn open_library(library_path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(library_path)?;
|
||||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
migrate_library(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Open the library db at `library_path`, run migrations, and ATTACH the
|
||||
/// reference db at `reference_path` under the [`REF_DB`] alias.
|
||||
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Connection> {
|
||||
|
||||
@@ -10,8 +10,11 @@ use crate::Catalog;
|
||||
impl Catalog {
|
||||
/// Create a deck. `commander_oracle_id` is optional.
|
||||
pub fn create_deck(&self, name: &str, commander_oracle_id: Option<&str>) -> Result<Deck> {
|
||||
// Every deck needs a `uuid` from the moment it exists — it's the
|
||||
// identity sync matches on across devices (see migration 0005).
|
||||
self.conn.execute(
|
||||
"INSERT INTO decks(name, commander_oracle_id) VALUES (?1, ?2)",
|
||||
"INSERT INTO decks(name, commander_oracle_id, uuid)
|
||||
VALUES (?1, ?2, lower(hex(randomblob(16))))",
|
||||
params![name, commander_oracle_id],
|
||||
)?;
|
||||
let id = self.conn.last_insert_rowid();
|
||||
@@ -63,9 +66,11 @@ impl Catalog {
|
||||
let src = self
|
||||
.get_deck(id)?
|
||||
.ok_or_else(|| crate::error::CoreError::Other(format!("deck {id} not found")))?;
|
||||
// A duplicate is a genuinely new deck, so it gets its own identity
|
||||
// rather than inheriting the source's.
|
||||
self.conn.execute(
|
||||
"INSERT INTO decks(name, format, commander_oracle_id, notes)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
"INSERT INTO decks(name, format, commander_oracle_id, notes, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, lower(hex(randomblob(16))))",
|
||||
params![new_name, src.format, src.commander_oracle_id, src.notes],
|
||||
)?;
|
||||
let new_id = self.conn.last_insert_rowid();
|
||||
|
||||
@@ -13,7 +13,73 @@ use crate::Catalog;
|
||||
/// [`Catalog::search_reference`] — everything except `in_deck`/`location`,
|
||||
/// which only make sense against the owned library. Caller has already
|
||||
/// written the `FROM ... WHERE <ownership predicate>` prefix.
|
||||
fn push_card_filters(sql: &mut String, vals: &mut Vec<Value>, filter: &LibraryFilter) {
|
||||
fn push_card_filters(
|
||||
sql: &mut String,
|
||||
vals: &mut Vec<Value>,
|
||||
filter: &LibraryFilter,
|
||||
has_universes_beyond: bool,
|
||||
) {
|
||||
// The four card-origin flags all arrived in the same reference-schema
|
||||
// revision, so one capability check covers them. Skipped entirely against
|
||||
// an older db — referencing a missing column would make every search fail
|
||||
// rather than merely ignoring one filter.
|
||||
if has_universes_beyond {
|
||||
if filter.exclude_universes_beyond {
|
||||
sql.push_str(" AND oc.universes_beyond = 0");
|
||||
}
|
||||
if filter.exclude_digital_only {
|
||||
sql.push_str(" AND oc.digital_only = 0");
|
||||
}
|
||||
if filter.exclude_funny {
|
||||
sql.push_str(" AND oc.funny = 0");
|
||||
}
|
||||
// NULL power/toughness means "not a plain number" (or not a creature);
|
||||
// such cards drop out rather than being compared as 0.
|
||||
for (col, bound, op) in [
|
||||
("power", filter.power_min, ">="),
|
||||
("power", filter.power_max, "<="),
|
||||
("toughness", filter.toughness_min, ">="),
|
||||
("toughness", filter.toughness_max, "<="),
|
||||
] {
|
||||
if let Some(v) = bound {
|
||||
sql.push_str(&format!(" AND oc.{col} IS NOT NULL AND oc.{col} {op} ?"));
|
||||
vals.push(Value::Real(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cheapest known printing. Cards with no price at all read as 0 here, the
|
||||
// same as they display, so an "under $5" filter includes them.
|
||||
for (bound, op) in [(filter.price_min, ">="), (filter.price_max, "<=")] {
|
||||
if let Some(v) = bound {
|
||||
sql.push_str(&format!(
|
||||
" AND COALESCE({price}, 0) {op} ?",
|
||||
price = crate::db::price_subquery()
|
||||
));
|
||||
vals.push(Value::Real(v));
|
||||
}
|
||||
}
|
||||
if let Some(slug) = filter.legal_in.as_ref().filter(|s| !s.is_empty()) {
|
||||
if let Some(i) = crate::model::LEGALITY_FORMATS
|
||||
.iter()
|
||||
.position(|(key, _)| key == slug)
|
||||
{
|
||||
// 2 bits per format; 1 = legal, 3 = restricted (playable as a
|
||||
// single copy, so it counts). 0 = not legal, 2 = banned.
|
||||
sql.push_str(&format!(
|
||||
" AND ((oc.legalities >> {}) & 3) IN (1, 3)",
|
||||
i * 2
|
||||
));
|
||||
}
|
||||
}
|
||||
if filter.can_be_commander {
|
||||
// Legendary creatures, plus anything whose rules text grants it (some
|
||||
// planeswalkers, Grist). `Legendary%Creature` rather than a prefix
|
||||
// match so "Legendary Artifact Creature" is included.
|
||||
sql.push_str(
|
||||
" AND (oc.type_line LIKE 'Legendary%Creature%'
|
||||
OR LOWER(oc.oracle_text) LIKE '%can be your commander%')",
|
||||
);
|
||||
}
|
||||
if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
|
||||
sql.push_str(" AND oc.name_lower LIKE ?");
|
||||
vals.push(Value::Text(format!("%{}%", text.to_lowercase())));
|
||||
@@ -109,7 +175,7 @@ impl Catalog {
|
||||
horizontal = self.horizontal_col()
|
||||
);
|
||||
let mut vals: Vec<Value> = Vec::new();
|
||||
push_card_filters(&mut sql, &mut vals, filter);
|
||||
push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond);
|
||||
|
||||
if let Some(deck_id) = filter.in_deck {
|
||||
sql.push_str(
|
||||
@@ -169,7 +235,7 @@ impl Catalog {
|
||||
horizontal = self.horizontal_col()
|
||||
);
|
||||
let mut vals: Vec<Value> = Vec::new();
|
||||
push_card_filters(&mut sql, &mut vals, filter);
|
||||
push_card_filters(&mut sql, &mut vals, filter, self.has_universes_beyond);
|
||||
|
||||
sql.push_str(" ORDER BY oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?");
|
||||
vals.push(Value::Integer(limit as i64));
|
||||
@@ -211,6 +277,154 @@ mod tests {
|
||||
assert_eq!(all.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excluding_universes_beyond_hides_flagged_cards_only_when_asked() {
|
||||
let fx = owned_fixture();
|
||||
// Stand in for what the ingest derives: a card that exists only as
|
||||
// Universes Beyond.
|
||||
fx.catalog
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE ref.oracle_cards SET universes_beyond = 1 WHERE oracle_id = 'serra-angel'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let f = LibraryFilter {
|
||||
exclude_universes_beyond: true,
|
||||
..Default::default()
|
||||
};
|
||||
let got = names(&fx.catalog.search_library(&f).unwrap());
|
||||
assert!(!got.contains(&"Serra Angel".to_string()), "flagged card is hidden");
|
||||
assert!(got.contains(&"Lightning Bolt".to_string()), "others are kept");
|
||||
|
||||
// Off unless asked for — this must not quietly change existing searches.
|
||||
let all = names(&fx.catalog.search_library(&LibraryFilter::default()).unwrap());
|
||||
assert!(all.contains(&"Serra Angel".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn price_range_treats_an_unknown_price_as_free() {
|
||||
let fx = owned_fixture();
|
||||
// Fixture cards have no printings, so no known price at all.
|
||||
let cheap = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
price_max: Some(5.0),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(
|
||||
cheap.contains(&"Sol Ring".to_string()),
|
||||
"priceless cards read as $0, so a max filter keeps them"
|
||||
);
|
||||
|
||||
let pricey = fx
|
||||
.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
price_min: Some(1.0),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(pricey.is_empty(), "and a min filter drops them");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legality_filter_accepts_legal_and_restricted_but_not_banned() {
|
||||
let fx = owned_fixture();
|
||||
let conn = &fx.catalog.conn;
|
||||
// commander is index 6 of LEGALITY_FORMATS, so bits 12-13.
|
||||
let set = |oid: &str, status: i64| {
|
||||
conn.execute(
|
||||
"UPDATE ref.oracle_cards SET legalities = ?2 WHERE oracle_id = ?1",
|
||||
rusqlite::params![oid, status << 12],
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
set("llanowar-elves", 1); // legal
|
||||
set("sol-ring", 3); // restricted — still playable as one copy
|
||||
set("lightning-bolt", 2); // banned
|
||||
set("serra-angel", 0); // not legal
|
||||
|
||||
let got = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
legal_in: Some("commander".into()),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(got.contains(&"Llanowar Elves".to_string()));
|
||||
assert!(got.contains(&"Sol Ring".to_string()));
|
||||
assert!(!got.contains(&"Lightning Bolt".to_string()), "banned is excluded");
|
||||
assert!(!got.contains(&"Serra Angel".to_string()), "not-legal is excluded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commander_filter_matches_legendary_creatures_and_granted_text() {
|
||||
let fx = owned_fixture();
|
||||
let conn = &fx.catalog.conn;
|
||||
conn.execute(
|
||||
"UPDATE ref.oracle_cards SET type_line = 'Legendary Artifact Creature — Golem'
|
||||
WHERE oracle_id = 'sol-ring'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE ref.oracle_cards SET oracle_text = 'Grist can be your commander.'
|
||||
WHERE oracle_id = 'lightning-bolt'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let got = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
can_be_commander: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(
|
||||
got.contains(&"Sol Ring".to_string()),
|
||||
"\"Legendary Artifact Creature\" must count, not just a prefix match"
|
||||
);
|
||||
assert!(got.contains(&"Lightning Bolt".to_string()), "granted by rules text");
|
||||
assert!(!got.contains(&"Serra Angel".to_string()), "plain creature is not");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_power_range_drops_cards_whose_power_isnt_a_number() {
|
||||
let fx = owned_fixture();
|
||||
let conn = &fx.catalog.conn;
|
||||
conn.execute(
|
||||
"UPDATE ref.oracle_cards SET power = 6 WHERE oracle_id = 'serra-angel'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// A Tarmogoyf-alike: printed "*", so stored NULL.
|
||||
conn.execute(
|
||||
"UPDATE ref.oracle_cards SET power = NULL WHERE oracle_id = 'llanowar-elves'",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let got = names(
|
||||
&fx.catalog
|
||||
.search_library(&LibraryFilter {
|
||||
power_min: Some(5.0),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec!["Serra Angel".to_string()],
|
||||
"a '*' power can't be compared, so it must not be treated as 0 or matched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commander_identity_subset() {
|
||||
let fx = owned_fixture();
|
||||
|
||||
@@ -456,6 +456,12 @@ struct ScryOracle {
|
||||
/// some cards. Kept nullable — see `synergy::CardSuggestion::edhrec_rank`.
|
||||
#[serde(default)]
|
||||
edhrec_rank: Option<i64>,
|
||||
/// Printed power/toughness. Strings because Magic allows non-numeric
|
||||
/// values ("*", "1+*", "X"); those parse to None and stay unfilterable.
|
||||
#[serde(default)]
|
||||
power: Option<String>,
|
||||
#[serde(default)]
|
||||
toughness: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -463,6 +469,10 @@ struct ScryFace {
|
||||
#[serde(default)]
|
||||
type_line: Option<String>,
|
||||
#[serde(default)]
|
||||
power: Option<String>,
|
||||
#[serde(default)]
|
||||
toughness: Option<String>,
|
||||
#[serde(default)]
|
||||
colors: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
oracle_text: Option<String>,
|
||||
@@ -470,6 +480,16 @@ struct ScryFace {
|
||||
image_uris: Option<ImageUris>,
|
||||
}
|
||||
|
||||
/// Parse a printed power/toughness into a number, or `None` when it isn't one.
|
||||
///
|
||||
/// Magic prints values like `*`, `1+*` and `X` whose real value depends on game
|
||||
/// state. Coercing those to 0 would quietly file every Tarmogoyf under "power
|
||||
/// 0"; leaving them NULL drops them from range filters instead, which is the
|
||||
/// honest answer to "power >= 5".
|
||||
fn numeric_pt(raw: Option<&str>) -> Option<f64> {
|
||||
raw?.trim().parse::<f64>().ok()
|
||||
}
|
||||
|
||||
fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken) -> Result<usize> {
|
||||
conn.execute_batch("BEGIN")?;
|
||||
let mut count = 0usize;
|
||||
@@ -478,8 +498,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
"INSERT OR IGNORE INTO oracle_cards
|
||||
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
|
||||
produced_mana,produces_colorless,legalities,themes,edhrec_rank,horizontal,
|
||||
image_uri,back_image_uri,default_scryfall_id)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)",
|
||||
image_uri,back_image_uri,default_scryfall_id,power,toughness)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19)",
|
||||
)?;
|
||||
let mut ins_fts =
|
||||
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
|
||||
@@ -601,6 +621,15 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
.and_then(|i| i.normal.clone())
|
||||
.unwrap_or_default();
|
||||
let default_scryfall_id = c.id.clone().unwrap_or_default();
|
||||
// Fall back to the front face: a transforming creature carries its
|
||||
// P/T per-face rather than at the top level.
|
||||
let front = c.card_faces.as_ref().and_then(|f| f.first());
|
||||
let power = numeric_pt(
|
||||
c.power.as_deref().or_else(|| front.and_then(|f| f.power.as_deref())),
|
||||
);
|
||||
let toughness = numeric_pt(
|
||||
c.toughness.as_deref().or_else(|| front.and_then(|f| f.toughness.as_deref())),
|
||||
);
|
||||
|
||||
let changed = ins_card.execute(params![
|
||||
oracle_id,
|
||||
@@ -620,6 +649,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
||||
image_uri,
|
||||
back_image_uri,
|
||||
default_scryfall_id,
|
||||
power,
|
||||
toughness,
|
||||
])?;
|
||||
// Only emit satellite rows the first time we see this oracle_id.
|
||||
if changed == 1 {
|
||||
@@ -690,6 +721,19 @@ struct ScryPrinting {
|
||||
card_faces: Option<Vec<PrintFace>>,
|
||||
#[serde(default)]
|
||||
prices: Option<ScryPrices>,
|
||||
/// Scryfall's per-printing markers. Carries `"universesbeyond"` on cards
|
||||
/// from another IP — the only field that reliably identifies them; there is
|
||||
/// no Universes Beyond `set_type`, and the security stamp is absent at low
|
||||
/// rarities.
|
||||
#[serde(default)]
|
||||
promo_types: Vec<String>,
|
||||
/// Where this printing can be played. Missing "paper" means it exists only
|
||||
/// on Arena/MTGO and can never be part of a physical collection.
|
||||
#[serde(default)]
|
||||
games: Vec<String>,
|
||||
/// Scryfall's set category; `"funny"` covers the Un-sets.
|
||||
#[serde(default)]
|
||||
set_type: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -716,8 +760,8 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
|
||||
{
|
||||
let mut ins = conn.prepare(
|
||||
"INSERT OR IGNORE INTO printings
|
||||
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
||||
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri,back_image_uri,price_usd,universes_beyond,digital,funny)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",
|
||||
)?;
|
||||
stream_jsonl::<_, ScryPrinting, _>(reader, |p| {
|
||||
if cancel.is_cancelled() {
|
||||
@@ -754,6 +798,11 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
|
||||
.and_then(|pr| pr.usd.as_ref())
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
let universes_beyond = p.promo_types.iter().any(|t| t == "universesbeyond");
|
||||
// An empty `games` (some tokens/oddities) is treated as paper —
|
||||
// better to leave a card visible than to hide a real one.
|
||||
let digital = !p.games.is_empty() && !p.games.iter().any(|g| g == "paper");
|
||||
let funny = p.set_type == "funny";
|
||||
ins.execute(params![
|
||||
p.id,
|
||||
oracle_id,
|
||||
@@ -764,11 +813,32 @@ fn load_printings(conn: &Connection, reader: impl Read, cancel: &CancelToken) ->
|
||||
image,
|
||||
back_image,
|
||||
price_usd,
|
||||
universes_beyond as i64,
|
||||
digital as i64,
|
||||
funny as i64,
|
||||
])?;
|
||||
count += 1;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
// Roll the per-printing flags up to the card. `MIN(...) = 1` means *every*
|
||||
// printing carries the flag, which is what distinguishes a card that only
|
||||
// exists as another IP's character (Frodo) from an ordinary card that
|
||||
// happens to have been reprinted into such a set (Lightning Bolt appears in
|
||||
// Marvel Commander). Using "any printing" instead would hide staples — and
|
||||
// the same reasoning applies to digital and funny printings, since paper
|
||||
// cards do get Arena versions and Un-set reprints.
|
||||
conn.execute_batch(
|
||||
"UPDATE oracle_cards SET
|
||||
universes_beyond = COALESCE((SELECT MIN(p.universes_beyond) FROM printings p
|
||||
WHERE p.oracle_id = oracle_cards.oracle_id), 0),
|
||||
digital_only = COALESCE((SELECT MIN(p.digital) FROM printings p
|
||||
WHERE p.oracle_id = oracle_cards.oracle_id), 0),
|
||||
funny = COALESCE((SELECT MIN(p.funny) FROM printings p
|
||||
WHERE p.oracle_id = oracle_cards.oracle_id), 0)
|
||||
WHERE EXISTS (SELECT 1 FROM printings p WHERE p.oracle_id = oracle_cards.oracle_id)",
|
||||
)?;
|
||||
conn.execute_batch("COMMIT")?;
|
||||
Ok(count)
|
||||
}
|
||||
@@ -904,6 +974,56 @@ mod tests {
|
||||
assert!(printings_w > oracle_w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_cards_whose_every_printing_is_universes_beyond_are_flagged() {
|
||||
// The distinction that matters: Frodo exists only as Lord of the Rings,
|
||||
// while Lightning Bolt is an ordinary card that happens to have been
|
||||
// reprinted into a Universes Beyond set. Flagging on "any printing is
|
||||
// UB" would hide staples from anyone filtering them out.
|
||||
let oracle = r#"[
|
||||
{"oracle_id":"oid-frodo","name":"Frodo, Adventurous Hobbit","cmc":2.0,
|
||||
"colors":["W"],"color_identity":["W"],"type_line":"Legendary Creature — Halfling Scout","keywords":[]},
|
||||
{"oracle_id":"oid-bolt","name":"Lightning Bolt","cmc":1.0,
|
||||
"colors":["R"],"color_identity":["R"],"type_line":"Instant","keywords":[]},
|
||||
{"oracle_id":"oid-plain","name":"Serra Angel","cmc":5.0,
|
||||
"colors":["W"],"color_identity":["W"],"type_line":"Creature — Angel","keywords":[]}
|
||||
]"#;
|
||||
let printings = r#"[
|
||||
{"id":"p-frodo-1","oracle_id":"oid-frodo","set":"ltr","collector_number":"1",
|
||||
"rarity":"rare","promo_types":["universesbeyond"]},
|
||||
{"id":"p-frodo-2","oracle_id":"oid-frodo","set":"ltc","collector_number":"2",
|
||||
"rarity":"rare","promo_types":["surgefoil","universesbeyond"]},
|
||||
{"id":"p-bolt-1","oracle_id":"oid-bolt","set":"lea","collector_number":"161",
|
||||
"rarity":"common"},
|
||||
{"id":"p-bolt-2","oracle_id":"oid-bolt","set":"msc","collector_number":"99",
|
||||
"rarity":"rare","promo_types":["universesbeyond"]},
|
||||
{"id":"p-plain-1","oracle_id":"oid-plain","set":"m19","collector_number":"34",
|
||||
"rarity":"uncommon"}
|
||||
]"#;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = open_reference_for_build(&dir.path().join("reference.db")).unwrap();
|
||||
load_oracle_cards(&conn, jsonl(oracle).as_bytes(), &CancelToken::new()).unwrap();
|
||||
load_printings(&conn, jsonl(printings).as_bytes(), &CancelToken::new()).unwrap();
|
||||
|
||||
let flagged = |oid: &str| -> i64 {
|
||||
conn.query_row(
|
||||
"SELECT universes_beyond FROM oracle_cards WHERE oracle_id = ?1",
|
||||
[oid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
assert_eq!(flagged("oid-frodo"), 1, "every printing is UB");
|
||||
assert_eq!(
|
||||
flagged("oid-bolt"),
|
||||
0,
|
||||
"a normal card reprinted into a UB set must not be flagged"
|
||||
);
|
||||
assert_eq!(flagged("oid-plain"), 0, "no UB printings at all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_and_printing_load_from_fixture_json() {
|
||||
// Minimal Scryfall-shaped fixtures exercising the insert paths.
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod ingest;
|
||||
pub mod library;
|
||||
pub mod model;
|
||||
pub mod search;
|
||||
pub mod sync;
|
||||
pub mod synergy;
|
||||
|
||||
use std::path::Path;
|
||||
@@ -43,6 +44,10 @@ pub struct Catalog {
|
||||
/// `default_scryfall_id` siblings) exist in the attached reference db.
|
||||
/// Cached at open time — see [`Catalog::image_expr`].
|
||||
pub(crate) has_oracle_images: bool,
|
||||
/// Whether `oracle_cards.universes_beyond` exists in the attached reference
|
||||
/// db. Cached at open time so the filter can be skipped (rather than
|
||||
/// failing every query) against a db built before it shipped.
|
||||
pub(crate) has_universes_beyond: bool,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
@@ -52,7 +57,14 @@ impl Catalog {
|
||||
let conn = db::open(library_path, reference_path)?;
|
||||
let has_horizontal_column = db::has_reference_column(&conn, "oracle_cards", "horizontal")?;
|
||||
let has_oracle_images = db::has_reference_column(&conn, "oracle_cards", "image_uri")?;
|
||||
Ok(Self { conn, has_horizontal_column, has_oracle_images })
|
||||
let has_universes_beyond =
|
||||
db::has_reference_column(&conn, "oracle_cards", "universes_beyond")?;
|
||||
Ok(Self {
|
||||
conn,
|
||||
has_horizontal_column,
|
||||
has_oracle_images,
|
||||
has_universes_beyond,
|
||||
})
|
||||
}
|
||||
|
||||
/// SQL expression for `oracle_cards.horizontal`, for use in a `SELECT`
|
||||
|
||||
@@ -287,6 +287,40 @@ pub struct LibraryFilter {
|
||||
pub location: Option<String>,
|
||||
/// Case-insensitive substring match against the card's oracle rules text.
|
||||
pub oracle_text: Option<String>,
|
||||
/// Hide cards that exist only as Universes Beyond (Lord of the Rings,
|
||||
/// Warhammer 40,000, Marvel, ...). Ordinary cards reprinted into a UB set
|
||||
/// are kept — see `oracle_cards.universes_beyond`.
|
||||
///
|
||||
/// Silently inert against a `reference.db` built before that column
|
||||
/// existed, the same way the other reference-schema additions degrade.
|
||||
#[serde(default)]
|
||||
pub exclude_universes_beyond: bool,
|
||||
/// Hide cards that exist only on Arena/MTGO (Alchemy rebalances and the
|
||||
/// like) and so can never be in a physical collection.
|
||||
#[serde(default)]
|
||||
pub exclude_digital_only: bool,
|
||||
/// Hide cards that exist only in "funny" sets (Unglued, Unhinged, Unstable,
|
||||
/// Unfinity).
|
||||
#[serde(default)]
|
||||
pub exclude_funny: bool,
|
||||
/// Cheapest known printing, in USD. Cards with no known price count as 0,
|
||||
/// matching how they're displayed.
|
||||
pub price_min: Option<f64>,
|
||||
pub price_max: Option<f64>,
|
||||
/// Only cards legal in this format (a [`LEGALITY_FORMATS`] slug, e.g.
|
||||
/// "commander"). Excludes banned and not-legal; restricted still counts as
|
||||
/// legal, since a restricted card is playable as a single copy.
|
||||
pub legal_in: Option<String>,
|
||||
/// Only cards that can head a Commander deck.
|
||||
#[serde(default)]
|
||||
pub can_be_commander: bool,
|
||||
/// Printed power/toughness range. Cards whose printed value isn't a plain
|
||||
/// number ("*", "1+*") are excluded from these, since they can't be
|
||||
/// compared — see `oracle_cards.power`.
|
||||
pub power_min: Option<f64>,
|
||||
pub power_max: Option<f64>,
|
||||
pub toughness_min: Option<f64>,
|
||||
pub toughness_max: Option<f64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
21
crates/mtgc-core/src/sql/library/0005_deck_uuid.sql
Normal file
21
crates/mtgc-core/src/sql/library/0005_deck_uuid.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- v5 — a stable, device-independent identity for decks.
|
||||
--
|
||||
-- Sync merges two copies of the library by matching entities across them.
|
||||
-- `library_cards` and `deck_cards` already have global keys (`oracle_id`, and
|
||||
-- `(deck, oracle_id)`), but `decks.id` is per-device autoincrement: two devices
|
||||
-- both hand out id 3 to different decks. Matching on `name` instead would work
|
||||
-- until someone renames a deck, at which point the merge sees a delete plus an
|
||||
-- unrelated create and the deck's contents are stranded.
|
||||
--
|
||||
-- This is the *only* schema sync needs. Change tracking is not stored here:
|
||||
-- the merge derives what changed (and what was deleted) by comparing against
|
||||
-- the last-synced document, so there are no timestamp columns, no tombstones,
|
||||
-- and no triggers.
|
||||
|
||||
ALTER TABLE decks ADD COLUMN uuid TEXT;
|
||||
|
||||
-- Existing decks predate the column. `randomblob` is SQLite's own CSPRNG, so
|
||||
-- this needs no help from Rust and no new dependency.
|
||||
UPDATE decks SET uuid = lower(hex(randomblob(16))) WHERE uuid IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX idx_decks_uuid ON decks(uuid);
|
||||
@@ -24,7 +24,27 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
|
||||
-- absent (falls back to `printings`).
|
||||
image_uri TEXT NOT NULL DEFAULT '',
|
||||
back_image_uri TEXT NOT NULL DEFAULT '',
|
||||
default_scryfall_id TEXT NOT NULL DEFAULT ''
|
||||
default_scryfall_id TEXT NOT NULL DEFAULT '',
|
||||
-- Boolean: this card exists *only* as Universes Beyond (Lord of the Rings,
|
||||
-- Warhammer 40,000, Marvel, ...). Derived after the printings pass from
|
||||
-- `printings.universes_beyond`, deliberately as "every printing is UB"
|
||||
-- rather than "some printing is UB" — ordinary cards get reprinted into UB
|
||||
-- sets (Lightning Bolt is in Marvel Commander) and must not be caught by
|
||||
-- a filter meant to exclude other IPs' characters.
|
||||
universes_beyond INTEGER NOT NULL DEFAULT 0,
|
||||
-- Boolean: exists only on Arena/MTGO (Alchemy rebalances and the like), so
|
||||
-- it can never be part of a physical collection. Same every-printing
|
||||
-- rollup as `universes_beyond`.
|
||||
digital_only INTEGER NOT NULL DEFAULT 0,
|
||||
-- Boolean: exists only in "funny" sets — Unglued, Unhinged, Unstable,
|
||||
-- Unfinity. Not legal in any normal format.
|
||||
funny INTEGER NOT NULL DEFAULT 0,
|
||||
-- Printed power/toughness, NULL when the card isn't a creature *or* when
|
||||
-- the value isn't a plain number ("*", "1+*", "X"). NULL therefore means
|
||||
-- "can't be compared numerically", and such cards drop out of a
|
||||
-- power/toughness range filter rather than being coerced to 0.
|
||||
power REAL,
|
||||
toughness REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
|
||||
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
|
||||
@@ -62,7 +82,13 @@ CREATE TABLE IF NOT EXISTS printings (
|
||||
rarity TEXT NOT NULL DEFAULT '',
|
||||
image_uri TEXT NOT NULL DEFAULT '',
|
||||
back_image_uri TEXT NOT NULL DEFAULT '', -- transform/MDFC/reversible back face
|
||||
price_usd REAL NOT NULL DEFAULT 0 -- Scryfall "prices.usd", 0 if unknown
|
||||
price_usd REAL NOT NULL DEFAULT 0, -- Scryfall "prices.usd", 0 if unknown
|
||||
-- Boolean: Scryfall lists "universesbeyond" in this printing's promo_types.
|
||||
universes_beyond INTEGER NOT NULL DEFAULT 0,
|
||||
-- Boolean: this printing isn't available on paper (Scryfall `games`).
|
||||
digital INTEGER NOT NULL DEFAULT 0,
|
||||
-- Boolean: this printing comes from a "funny" set (Scryfall `set_type`).
|
||||
funny INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id);
|
||||
|
||||
|
||||
789
crates/mtgc-core/src/sync.rs
Normal file
789
crates/mtgc-core/src/sync.rs
Normal file
@@ -0,0 +1,789 @@
|
||||
//! The library as a portable JSON document, and the merge that reconciles two
|
||||
//! copies of it.
|
||||
//!
|
||||
//! The document is what the user actually keeps: it lives in whatever folder
|
||||
//! they sync, and it is the only file they ever see. The SQLite database is an
|
||||
//! internal working copy that can always be rebuilt by merging a document into
|
||||
//! an empty one — which is why "first run", "cache was cleared", and "the
|
||||
//! document changed elsewhere" are all the same code path.
|
||||
//!
|
||||
//! # Why a three-way merge
|
||||
//!
|
||||
//! Reconciling two edited copies needs to know *who changed what*, and — the
|
||||
//! hard part — to tell "deleted over there" apart from "never existed over
|
||||
//! there". Given a common ancestor, both fall straight out of comparison. That
|
||||
//! is the whole reason this module needs no timestamp columns, no tombstone
|
||||
//! table, and no triggers: the ancestor carries the information they would have
|
||||
//! had to record.
|
||||
//!
|
||||
//! # The one subtle rule
|
||||
//!
|
||||
//! The stored ancestor must be **the remote document as last read**, never the
|
||||
//! result we last published. If a publish is lost — the sync tool dropped it,
|
||||
//! the process died, the drive was unplugged — the remote is still what we last
|
||||
//! read, so the next merge sees our edits as ours and republishes them.
|
||||
//! Recording the merged result instead would make us believe those edits were
|
||||
//! already shared, and the next merge would read their absence from the remote
|
||||
//! as a deletion and destroy them.
|
||||
//!
|
||||
//! # Conflicts
|
||||
//!
|
||||
//! When both sides changed the same field, [`merge3`] takes *theirs*. That is
|
||||
//! forced by convergence, not taste: "prefer ours" has each device re-asserting
|
||||
//! its own value forever, so the two never agree. Preferring theirs settles
|
||||
//! immediately, because after merging we hold the value they published. In
|
||||
//! practice it behaves as roughly last-published-wins. Every such choice is
|
||||
//! counted in [`MergeReport`] so the UI can tell the user what was overruled.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Bumped only for a breaking change to the document shape. Readers reject
|
||||
/// anything newer rather than silently misreading it.
|
||||
pub const DOC_VERSION: u32 = 1;
|
||||
|
||||
/// One owned card. Keyed by `oracle_id` in [`LibraryDoc::cards`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct CardEntry {
|
||||
pub quantity: i64,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub location: String,
|
||||
}
|
||||
|
||||
/// One card in a deck. Keyed by `oracle_id` in [`DeckDoc::cards`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct DeckCardEntry {
|
||||
pub quantity: i64,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub commander: bool,
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub maybeboard: bool,
|
||||
}
|
||||
|
||||
/// One deck. Keyed by its uuid in [`LibraryDoc::decks`] — see migration 0005
|
||||
/// for why the integer id can't be used.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct DeckDoc {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub format: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub commander: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub notes: String,
|
||||
#[serde(default)]
|
||||
pub cards: BTreeMap<String, DeckCardEntry>,
|
||||
}
|
||||
|
||||
/// The user's whole library.
|
||||
///
|
||||
/// `BTreeMap` throughout so serialisation is deterministic: the same library
|
||||
/// always produces byte-identical JSON, which keeps sync tools from seeing
|
||||
/// spurious changes and makes the file diffable by hand.
|
||||
///
|
||||
/// `library_card_copies` is deliberately absent — that table is unused by any
|
||||
/// code in the project.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LibraryDoc {
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub cards: BTreeMap<String, CardEntry>,
|
||||
#[serde(default)]
|
||||
pub decks: BTreeMap<String, DeckDoc>,
|
||||
}
|
||||
|
||||
impl Default for LibraryDoc {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: DOC_VERSION,
|
||||
cards: BTreeMap::new(),
|
||||
decks: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::trivially_copy_pass_by_ref)]
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
impl LibraryDoc {
|
||||
pub fn to_json(&self) -> Result<String> {
|
||||
Ok(serde_json::to_string_pretty(self)?)
|
||||
}
|
||||
|
||||
/// Parse a document, rejecting one written by a newer version of the app
|
||||
/// rather than silently dropping fields it doesn't understand.
|
||||
pub fn from_json(text: &str) -> Result<Self> {
|
||||
let doc: Self = serde_json::from_str(text)?;
|
||||
if doc.version > DOC_VERSION {
|
||||
return Err(crate::error::CoreError::Other(format!(
|
||||
"this library file was written by a newer version of mtgc \
|
||||
(format {}, this build understands {DOC_VERSION}) — update the app first",
|
||||
doc.version
|
||||
)));
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.cards.is_empty() && self.decks.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a merge did, for reporting to the user.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct MergeReport {
|
||||
pub cards_changed: usize,
|
||||
pub decks_changed: usize,
|
||||
pub decks_removed: usize,
|
||||
/// Fields both sides changed differently, where theirs was taken. Described
|
||||
/// in user terms (a deck or card name), not field paths.
|
||||
pub conflicts: Vec<String>,
|
||||
}
|
||||
|
||||
impl MergeReport {
|
||||
/// Whether anything at all changed, so callers can stay quiet about the
|
||||
/// no-op merges that make up almost every sync.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.cards_changed == 0 && self.decks_changed == 0 && self.decks_removed == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile our copy with theirs, given the common ancestor both descend from.
|
||||
///
|
||||
/// A pure function — no database, no filesystem, no clock — which is what makes
|
||||
/// the interesting cases (concurrent edits, deletes racing edits, idempotency)
|
||||
/// directly testable.
|
||||
///
|
||||
/// Per key, across `base`/`ours`/`theirs`:
|
||||
/// - unchanged on one side → take the other side's version
|
||||
/// - changed identically → no conflict, either will do
|
||||
/// - changed differently → take theirs, and record it
|
||||
/// - present in base, gone from one side → that side deleted it; the deletion
|
||||
/// wins unless the other side also *changed* it, in which case the edit does
|
||||
/// (destroying edited work is worse than resurrecting a deleted deck, which
|
||||
/// the user can simply delete again)
|
||||
pub fn merge3(base: &LibraryDoc, ours: &LibraryDoc, theirs: &LibraryDoc) -> (LibraryDoc, MergeReport) {
|
||||
let mut report = MergeReport::default();
|
||||
let mut out = LibraryDoc::default();
|
||||
|
||||
for id in keys(&base.cards, &ours.cards, &theirs.cards) {
|
||||
let (b, o, t) = (base.cards.get(&id), ours.cards.get(&id), theirs.cards.get(&id));
|
||||
if let Some(entry) = merge_entry(b, o, t, &mut || {
|
||||
report.conflicts.push(format!("owned quantity for card {id}"));
|
||||
}) {
|
||||
if Some(&entry) != o {
|
||||
report.cards_changed += 1;
|
||||
}
|
||||
out.cards.insert(id, entry);
|
||||
} else if o.is_some() {
|
||||
// Cards are never deleted by the app (quantity drops to zero
|
||||
// instead), so this only happens if a document was hand-edited.
|
||||
report.cards_changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for id in keys(&base.decks, &ours.decks, &theirs.decks) {
|
||||
let (b, o, t) = (base.decks.get(&id), ours.decks.get(&id), theirs.decks.get(&id));
|
||||
match (b, o, t) {
|
||||
// Deleted on one side; kept and untouched on the other.
|
||||
(Some(bd), Some(od), None) if od == bd => {
|
||||
report.decks_removed += 1;
|
||||
continue;
|
||||
}
|
||||
(Some(bd), None, Some(td)) if td == bd => {
|
||||
report.decks_removed += 1;
|
||||
continue;
|
||||
}
|
||||
// Deleted on both.
|
||||
(Some(_), None, None) => {
|
||||
report.decks_removed += 1;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let name = o.or(t).or(b).map(|d| d.name.clone()).unwrap_or_default();
|
||||
let merged = merge_deck(b, o, t, &name, &mut report);
|
||||
if Some(&merged) != o {
|
||||
report.decks_changed += 1;
|
||||
}
|
||||
out.decks.insert(id, merged);
|
||||
}
|
||||
|
||||
(out, report)
|
||||
}
|
||||
|
||||
/// Union of the keys of three maps, in deterministic order.
|
||||
fn keys<V>(
|
||||
base: &BTreeMap<String, V>,
|
||||
ours: &BTreeMap<String, V>,
|
||||
theirs: &BTreeMap<String, V>,
|
||||
) -> BTreeSet<String> {
|
||||
base.keys()
|
||||
.chain(ours.keys())
|
||||
.chain(theirs.keys())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Three-way merge of a single value. `None` means "not present on this side".
|
||||
/// Returns `None` when the entry should not exist in the result.
|
||||
fn merge_entry<T: Clone + PartialEq>(
|
||||
base: Option<&T>,
|
||||
ours: Option<&T>,
|
||||
theirs: Option<&T>,
|
||||
on_conflict: &mut dyn FnMut(),
|
||||
) -> Option<T> {
|
||||
match (base, ours, theirs) {
|
||||
(_, None, None) => None,
|
||||
// Only one side has it: either it was just added there, or the other
|
||||
// side deleted it. Deletion only wins if the surviving side left it
|
||||
// exactly as the ancestor had it.
|
||||
(Some(b), Some(o), None) => (o != b).then(|| o.clone()),
|
||||
(Some(b), None, Some(t)) => (t != b).then(|| t.clone()),
|
||||
(None, Some(o), None) => Some(o.clone()),
|
||||
(None, None, Some(t)) => Some(t.clone()),
|
||||
(base, Some(o), Some(t)) => {
|
||||
if o == t {
|
||||
return Some(o.clone());
|
||||
}
|
||||
match base {
|
||||
Some(b) if o == b => Some(t.clone()), // only they changed it
|
||||
Some(b) if t == b => Some(o.clone()), // only we changed it
|
||||
_ => {
|
||||
// Both changed it, differently — or there's no ancestor and
|
||||
// they disagree. Theirs wins, for convergence.
|
||||
on_conflict();
|
||||
Some(t.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_deck(
|
||||
base: Option<&DeckDoc>,
|
||||
ours: Option<&DeckDoc>,
|
||||
theirs: Option<&DeckDoc>,
|
||||
name: &str,
|
||||
report: &mut MergeReport,
|
||||
) -> DeckDoc {
|
||||
// Present on only one side. Reaching here means the other side deleted it
|
||||
// *and* this side changed it, so the surviving version is taken whole — an
|
||||
// absent deck must not be treated as an empty one, or its fields would lose
|
||||
// a field-by-field merge against a deck full of empty strings.
|
||||
match (ours, theirs) {
|
||||
(Some(o), None) => return o.clone(),
|
||||
(None, Some(t)) => return t.clone(),
|
||||
(None, None) => return DeckDoc::default(), // filtered out by merge3
|
||||
_ => {}
|
||||
}
|
||||
let empty = DeckDoc::default();
|
||||
let o = ours.unwrap_or(&empty);
|
||||
let t = theirs.unwrap_or(&empty);
|
||||
let b = base.unwrap_or(&empty);
|
||||
|
||||
// Scalar fields merge independently, so renaming a deck here while changing
|
||||
// its format there keeps both.
|
||||
let mut merged = DeckDoc {
|
||||
name: merge_field(&b.name, &o.name, &t.name, || {
|
||||
report.conflicts.push(format!("name of deck \"{name}\""))
|
||||
}),
|
||||
format: merge_field(&b.format, &o.format, &t.format, || {
|
||||
report.conflicts.push(format!("format of deck \"{name}\""))
|
||||
}),
|
||||
commander: merge_field(&b.commander, &o.commander, &t.commander, || {
|
||||
report.conflicts.push(format!("commander of deck \"{name}\""))
|
||||
}),
|
||||
notes: merge_field(&b.notes, &o.notes, &t.notes, || {
|
||||
report.conflicts.push(format!("notes of deck \"{name}\""))
|
||||
}),
|
||||
cards: BTreeMap::new(),
|
||||
};
|
||||
|
||||
for id in keys(&b.cards, &o.cards, &t.cards) {
|
||||
let entry = merge_entry(
|
||||
b.cards.get(&id),
|
||||
o.cards.get(&id),
|
||||
t.cards.get(&id),
|
||||
&mut || {
|
||||
report
|
||||
.conflicts
|
||||
.push(format!("card {id} in deck \"{name}\""))
|
||||
},
|
||||
);
|
||||
if let Some(entry) = entry {
|
||||
merged.cards.insert(id, entry);
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// Three-way merge of an always-present field.
|
||||
fn merge_field<T: Clone + PartialEq>(base: &T, ours: &T, theirs: &T, on_conflict: impl FnOnce()) -> T {
|
||||
if ours == theirs || theirs == base {
|
||||
return ours.clone();
|
||||
}
|
||||
if ours == base {
|
||||
return theirs.clone();
|
||||
}
|
||||
on_conflict();
|
||||
theirs.clone()
|
||||
}
|
||||
|
||||
/// Serialise the working database into a document.
|
||||
pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
|
||||
let mut doc = LibraryDoc::default();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT oracle_id, quantity_owned, location FROM library_cards
|
||||
WHERE quantity_owned <> 0 OR location <> ''",
|
||||
)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
doc.cards.insert(
|
||||
row.get(0)?,
|
||||
CardEntry {
|
||||
quantity: row.get(1)?,
|
||||
location: row.get(2)?,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare("SELECT id, uuid, name, format, commander_oracle_id, notes FROM decks")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
let mut by_id: BTreeMap<i64, String> = BTreeMap::new();
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let uuid: String = row.get(1)?;
|
||||
by_id.insert(id, uuid.clone());
|
||||
doc.decks.insert(
|
||||
uuid,
|
||||
DeckDoc {
|
||||
name: row.get(2)?,
|
||||
format: row.get(3)?,
|
||||
commander: row.get(4)?,
|
||||
notes: row.get(5)?,
|
||||
cards: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard FROM deck_cards")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let deck_id: i64 = row.get(0)?;
|
||||
let Some(uuid) = by_id.get(&deck_id) else {
|
||||
continue; // Orphan row; the foreign key should make this impossible.
|
||||
};
|
||||
if let Some(deck) = doc.decks.get_mut(uuid) {
|
||||
deck.cards.insert(
|
||||
row.get(1)?,
|
||||
DeckCardEntry {
|
||||
quantity: row.get(2)?,
|
||||
commander: row.get::<_, i64>(3)? != 0,
|
||||
maybeboard: row.get::<_, i64>(4)? != 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Reconcile the working database to `doc`, in one transaction.
|
||||
///
|
||||
/// Rows absent from the document are removed, so this is a true "make the
|
||||
/// database equal this document" — which is what lets the database be treated
|
||||
/// as a rebuildable cache rather than a second source of truth.
|
||||
pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
|
||||
for (oracle_id, entry) in &doc.cards {
|
||||
tx.execute(
|
||||
"INSERT INTO library_cards(oracle_id, quantity_owned, location) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(oracle_id) DO UPDATE SET
|
||||
quantity_owned = excluded.quantity_owned,
|
||||
location = excluded.location",
|
||||
rusqlite::params![oracle_id, entry.quantity, entry.location],
|
||||
)?;
|
||||
}
|
||||
// Cards the document doesn't mention are zeroed rather than deleted, so any
|
||||
// deck still referencing them keeps working.
|
||||
{
|
||||
let keep = json_array(doc.cards.keys())?;
|
||||
tx.execute(
|
||||
"UPDATE library_cards SET quantity_owned = 0, location = ''
|
||||
WHERE oracle_id NOT IN (SELECT value FROM json_each(?1))",
|
||||
[&keep],
|
||||
)?;
|
||||
}
|
||||
|
||||
// Decks are matched by uuid; anything else is a deck the document no longer
|
||||
// has. ON DELETE CASCADE clears its cards.
|
||||
{
|
||||
let keep = json_array(doc.decks.keys())?;
|
||||
tx.execute(
|
||||
"DELETE FROM decks WHERE uuid NOT IN (SELECT value FROM json_each(?1))",
|
||||
[&keep],
|
||||
)?;
|
||||
}
|
||||
|
||||
for (uuid, deck) in &doc.decks {
|
||||
tx.execute(
|
||||
"INSERT INTO decks(uuid, name, format, commander_oracle_id, notes)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(uuid) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
format = excluded.format,
|
||||
commander_oracle_id = excluded.commander_oracle_id,
|
||||
notes = excluded.notes",
|
||||
rusqlite::params![uuid, deck.name, deck.format, deck.commander, deck.notes],
|
||||
)?;
|
||||
let deck_id: i64 =
|
||||
tx.query_row("SELECT id FROM decks WHERE uuid = ?1", [uuid], |r| r.get(0))?;
|
||||
|
||||
for (oracle_id, card) in &deck.cards {
|
||||
tx.execute(
|
||||
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
|
||||
quantity = excluded.quantity,
|
||||
is_commander = excluded.is_commander,
|
||||
is_maybeboard = excluded.is_maybeboard",
|
||||
rusqlite::params![
|
||||
deck_id,
|
||||
oracle_id,
|
||||
card.quantity,
|
||||
card.commander as i64,
|
||||
card.maybeboard as i64
|
||||
],
|
||||
)?;
|
||||
}
|
||||
let keep = json_array(deck.cards.keys())?;
|
||||
tx.execute(
|
||||
"DELETE FROM deck_cards
|
||||
WHERE deck_id = ?1 AND oracle_id NOT IN (SELECT value FROM json_each(?2))",
|
||||
rusqlite::params![deck_id, keep],
|
||||
)?;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render keys as a JSON array, for `json_each` to expand inside a query —
|
||||
/// SQLite has no array parameter, and this avoids building SQL by
|
||||
/// concatenation.
|
||||
fn json_array<'a>(keys: impl Iterator<Item = &'a String>) -> Result<String> {
|
||||
Ok(serde_json::to_string(&keys.collect::<Vec<_>>())?)
|
||||
}
|
||||
|
||||
/// Read a library database written before the document format existed (schema
|
||||
/// v1–v4, where `library.db` was the file the user kept) and produce a document.
|
||||
///
|
||||
/// Migrations run on open, so the file is upgraded to the current schema first;
|
||||
/// that's what gives its decks the uuids the document needs.
|
||||
pub fn convert_legacy_library(path: &Path) -> Result<LibraryDoc> {
|
||||
let conn = crate::db::open_library(path)?;
|
||||
read_doc(&conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Catalog;
|
||||
|
||||
fn doc(cards: &[(&str, i64)], decks: &[(&str, &str, &[(&str, i64)])]) -> LibraryDoc {
|
||||
let mut d = LibraryDoc::default();
|
||||
for (id, qty) in cards {
|
||||
d.cards.insert(
|
||||
(*id).into(),
|
||||
CardEntry { quantity: *qty, location: String::new() },
|
||||
);
|
||||
}
|
||||
for (uuid, name, cards) in decks {
|
||||
let mut deck = DeckDoc { name: (*name).into(), ..Default::default() };
|
||||
for (id, qty) in *cards {
|
||||
deck.cards.insert(
|
||||
(*id).into(),
|
||||
DeckCardEntry { quantity: *qty, ..Default::default() },
|
||||
);
|
||||
}
|
||||
d.decks.insert((*uuid).into(), deck);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn qty(d: &LibraryDoc, id: &str) -> Option<i64> {
|
||||
d.cards.get(id).map(|c| c.quantity)
|
||||
}
|
||||
|
||||
// --- the merge, as a pure function ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn edits_to_different_cards_on_each_side_both_survive() {
|
||||
// The scenario this whole design exists for: edit offline on the phone,
|
||||
// edit something else on the desktop, then sync.
|
||||
let base = doc(&[("a", 1), ("b", 1)], &[]);
|
||||
let ours = doc(&[("a", 3), ("b", 1)], &[]);
|
||||
let theirs = doc(&[("a", 1), ("b", 5)], &[]);
|
||||
|
||||
let (merged, report) = merge3(&base, &ours, &theirs);
|
||||
|
||||
assert_eq!(qty(&merged, "a"), Some(3));
|
||||
assert_eq!(qty(&merged, "b"), Some(5));
|
||||
assert!(report.conflicts.is_empty(), "these edits don't collide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_card_changed_on_both_sides_takes_theirs_and_reports_it() {
|
||||
let base = doc(&[("a", 1)], &[]);
|
||||
let ours = doc(&[("a", 3)], &[]);
|
||||
let theirs = doc(&[("a", 9)], &[]);
|
||||
|
||||
let (merged, report) = merge3(&base, &ours, &theirs);
|
||||
|
||||
assert_eq!(qty(&merged, "a"), Some(9));
|
||||
assert_eq!(report.conflicts.len(), 1, "the user must be told");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferring_theirs_converges_rather_than_ping_ponging() {
|
||||
// Both devices resolve the same clash, then exchange again. If the rule
|
||||
// were "prefer ours" they would swap values forever; this pins down
|
||||
// that a second exchange is a no-op.
|
||||
let base = doc(&[("a", 1)], &[]);
|
||||
let phone = doc(&[("a", 3)], &[]);
|
||||
let desktop = doc(&[("a", 9)], &[]);
|
||||
|
||||
let (phone_after, _) = merge3(&base, &phone, &desktop);
|
||||
let (desktop_after, _) = merge3(&base, &desktop, &phone_after);
|
||||
assert_eq!(qty(&phone_after, "a"), qty(&desktop_after, "a"));
|
||||
|
||||
let (phone_again, _) = merge3(&phone_after, &phone_after, &desktop_after);
|
||||
assert_eq!(phone_again, phone_after, "a second exchange must settle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_the_same_document_twice_changes_nothing() {
|
||||
let base = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]);
|
||||
let ours = doc(&[("a", 3)], &[("u1", "Fub", &[("a", 2)])]);
|
||||
let theirs = doc(&[("a", 1), ("b", 4)], &[("u1", "Fub", &[("a", 1)])]);
|
||||
|
||||
let (once, _) = merge3(&base, &ours, &theirs);
|
||||
let (twice, report) = merge3(&theirs, &once, &theirs);
|
||||
|
||||
assert_eq!(once, twice, "merge must be idempotent");
|
||||
assert!(report.is_empty(), "a re-merge should report no changes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deck_deleted_on_one_side_stays_deleted() {
|
||||
let base = doc(&[], &[("u1", "Fub", &[])]);
|
||||
let ours = doc(&[], &[("u1", "Fub", &[])]);
|
||||
let theirs = doc(&[], &[]);
|
||||
|
||||
let (merged, report) = merge3(&base, &ours, &theirs);
|
||||
|
||||
assert!(merged.decks.is_empty());
|
||||
assert_eq!(report.decks_removed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deck_edited_on_one_side_survives_deletion_on_the_other() {
|
||||
// Losing edited work is worse than resurrecting a deck the user can
|
||||
// simply delete again.
|
||||
let base = doc(&[], &[("u1", "Fub", &[])]);
|
||||
let ours = doc(&[], &[("u1", "Renamed", &[])]);
|
||||
let theirs = doc(&[], &[]);
|
||||
|
||||
let (merged, _) = merge3(&base, &ours, &theirs);
|
||||
|
||||
assert_eq!(merged.decks.get("u1").map(|d| d.name.as_str()), Some("Renamed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deck_we_deleted_comes_back_if_they_edited_it() {
|
||||
// The mirror of the case above: the rule has to hold whichever side
|
||||
// did the deleting, or it would depend on who happens to sync first.
|
||||
let base = doc(&[], &[("u1", "Fub", &[])]);
|
||||
let ours = doc(&[], &[]);
|
||||
let theirs = doc(&[], &[("u1", "Fub", &[("a", 2)])]);
|
||||
|
||||
let (merged, _) = merge3(&base, &ours, &theirs);
|
||||
|
||||
let deck = merged.decks.get("u1").expect("their edit should win");
|
||||
assert_eq!(deck.name, "Fub");
|
||||
assert_eq!(deck.cards["a"].quantity, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_base_never_deletes_anything() {
|
||||
// First sync, or after the working copy was rebuilt: with no ancestor,
|
||||
// nothing may be interpreted as a deletion.
|
||||
let base = LibraryDoc::default();
|
||||
let ours = doc(&[("a", 1)], &[("u1", "Mine", &[])]);
|
||||
let theirs = doc(&[("b", 2)], &[("u2", "Theirs", &[])]);
|
||||
|
||||
let (merged, report) = merge3(&base, &ours, &theirs);
|
||||
|
||||
assert_eq!(qty(&merged, "a"), Some(1));
|
||||
assert_eq!(qty(&merged, "b"), Some(2));
|
||||
assert_eq!(merged.decks.len(), 2);
|
||||
assert_eq!(report.decks_removed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lost_publish_is_re_detected_on_the_next_sync() {
|
||||
// Base is the remote *as read*, so if our publish never landed the
|
||||
// remote is unchanged and our edits are still recognisably ours.
|
||||
let remote = doc(&[("a", 1)], &[]);
|
||||
let ours = doc(&[("a", 3)], &[]);
|
||||
|
||||
let (published, _) = merge3(&remote, &ours, &remote);
|
||||
assert_eq!(qty(&published, "a"), Some(3));
|
||||
|
||||
// The publish is dropped; the remote still reads as it did before.
|
||||
let (retry, _) = merge3(&remote, &published, &remote);
|
||||
assert_eq!(qty(&retry, "a"), Some(3), "our edit must survive to be republished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_fields_merge_independently() {
|
||||
let mut base = doc(&[], &[("u1", "Fub", &[])]);
|
||||
let mut ours = base.clone();
|
||||
let mut theirs = base.clone();
|
||||
ours.decks.get_mut("u1").unwrap().name = "Renamed".into();
|
||||
theirs.decks.get_mut("u1").unwrap().format = "Commander".into();
|
||||
base.decks.get_mut("u1").unwrap().notes = String::new();
|
||||
|
||||
let (merged, report) = merge3(&base, &ours, &theirs);
|
||||
let deck = merged.decks.get("u1").unwrap();
|
||||
|
||||
assert_eq!(deck.name, "Renamed", "our rename survives");
|
||||
assert_eq!(deck.format, "Commander", "their format change survives");
|
||||
assert!(report.conflicts.is_empty());
|
||||
}
|
||||
|
||||
// --- document <-> database --------------------------------------------
|
||||
|
||||
fn catalog(dir: &Path, name: &str) -> Catalog {
|
||||
let reference = dir.join("reference.db");
|
||||
if !reference.exists() {
|
||||
crate::db::open_reference_for_build(&reference).unwrap();
|
||||
}
|
||||
Catalog::open(&dir.join(format!("{name}.db")), &reference).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_populated_library_round_trips_through_a_document() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let c = catalog(tmp.path(), "lib");
|
||||
let deck = c.create_deck("Fub", None).unwrap();
|
||||
c.set_deck_format(deck.id, "Commander").unwrap();
|
||||
c.set_deck_card(deck.id, "card-a", 2, false, false).unwrap();
|
||||
c.set_deck_card(deck.id, "card-b", 1, false, true).unwrap();
|
||||
c.set_owned("card-a", 4).unwrap();
|
||||
c.set_location("card-a", "Box 1").unwrap();
|
||||
|
||||
let before = read_doc(&c.conn).unwrap();
|
||||
// Round-trip through JSON too, so the serde attributes are exercised.
|
||||
let text = before.to_json().unwrap();
|
||||
let parsed = LibraryDoc::from_json(&text).unwrap();
|
||||
assert_eq!(before, parsed);
|
||||
|
||||
// Applying it to a *different*, empty library reproduces it exactly.
|
||||
let other = catalog(tmp.path(), "other");
|
||||
apply_doc(&other.conn, &parsed).unwrap();
|
||||
assert_eq!(read_doc(&other.conn).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_document_removes_what_it_no_longer_contains() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let c = catalog(tmp.path(), "lib");
|
||||
let keep = c.create_deck("Keep", None).unwrap();
|
||||
let drop = c.create_deck("Drop", None).unwrap();
|
||||
c.set_deck_card(keep.id, "card-a", 1, false, false).unwrap();
|
||||
c.set_deck_card(keep.id, "card-b", 1, false, false).unwrap();
|
||||
c.set_owned("card-a", 3).unwrap();
|
||||
|
||||
let mut d = read_doc(&c.conn).unwrap();
|
||||
let drop_uuid = d
|
||||
.decks
|
||||
.iter()
|
||||
.find(|(_, v)| v.name == "Drop")
|
||||
.map(|(k, _)| k.clone())
|
||||
.unwrap();
|
||||
d.decks.remove(&drop_uuid);
|
||||
let keep_uuid = d.decks.keys().next().unwrap().clone();
|
||||
d.decks.get_mut(&keep_uuid).unwrap().cards.remove("card-b");
|
||||
d.cards.remove("card-a");
|
||||
|
||||
apply_doc(&c.conn, &d).unwrap();
|
||||
|
||||
assert_eq!(c.list_decks().unwrap().len(), 1);
|
||||
assert_eq!(c.deck_contents(drop.id).unwrap().len(), 0);
|
||||
assert_eq!(read_doc(&c.conn).unwrap(), d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_deck_gets_a_uuid_whether_created_or_duplicated() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let c = catalog(tmp.path(), "lib");
|
||||
let a = c.create_deck("Fub", None).unwrap();
|
||||
c.duplicate_deck(a.id, "Fub copy").unwrap();
|
||||
|
||||
let d = read_doc(&c.conn).unwrap();
|
||||
assert_eq!(d.decks.len(), 2, "both decks must appear in the document");
|
||||
assert!(
|
||||
d.decks.keys().all(|u| u.len() == 32),
|
||||
"uuids should be 16 random bytes as hex"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_from_a_newer_app_version_is_refused() {
|
||||
let text = r#"{"version": 99, "cards": {}, "decks": {}}"#;
|
||||
let err = LibraryDoc::from_json(text).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("newer version"),
|
||||
"should explain the real problem, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_a_pre_document_library_file() {
|
||||
// A library from before this format existed: schema v4, no uuids.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let legacy = tmp.path().join("Decks-v1.db");
|
||||
{
|
||||
let c = catalog(tmp.path(), "Decks-v1");
|
||||
let deck = c.create_deck("Old Deck", None).unwrap();
|
||||
c.set_deck_format(deck.id, "Modern").unwrap();
|
||||
c.set_deck_card(deck.id, "card-a", 3, false, false).unwrap();
|
||||
c.set_deck_card(deck.id, "card-b", 1, false, true).unwrap();
|
||||
c.set_owned("card-a", 7).unwrap();
|
||||
c.set_location("card-a", "Binder A").unwrap();
|
||||
}
|
||||
|
||||
let d = convert_legacy_library(&legacy).unwrap();
|
||||
|
||||
assert_eq!(d.cards["card-a"].quantity, 7);
|
||||
assert_eq!(d.cards["card-a"].location, "Binder A");
|
||||
let deck = d.decks.values().next().expect("the deck should convert");
|
||||
assert_eq!(deck.name, "Old Deck");
|
||||
assert_eq!(deck.format, "Modern");
|
||||
assert_eq!(deck.cards["card-a"].quantity, 3);
|
||||
assert!(deck.cards["card-b"].maybeboard);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- Note: library sync needs no storage permission. The user grants access
|
||||
to one folder through the system folder picker (SAF), and that grant is
|
||||
persisted across restarts — see SyncPlugin.kt. -->
|
||||
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.garrettmills.mtgc
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.activity.result.ActivityResult
|
||||
import app.tauri.annotation.ActivityCallback
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.Invoke
|
||||
import app.tauri.plugin.JSArray
|
||||
import app.tauri.plugin.JSObject
|
||||
import app.tauri.plugin.Plugin
|
||||
import java.io.File
|
||||
|
||||
@InvokeArg
|
||||
class FileArgs {
|
||||
/** File name inside the chosen folder, e.g. "library.json". */
|
||||
lateinit var name: String
|
||||
|
||||
/** App-private staging path the bytes are copied to/from. */
|
||||
lateinit var path: String
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
class NameArgs {
|
||||
lateinit var name: String
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the Storage Access Framework to Rust for library sync.
|
||||
*
|
||||
* The user picks one folder through the system picker; we take a *persistable*
|
||||
* URI permission on it so the grant survives restarts. That folder is a real
|
||||
* directory in shared storage (e.g. /storage/emulated/0/mtgc), so a sync tool
|
||||
* like Syncthing can see it, but this app reaches it only through SAF and
|
||||
* therefore needs no storage permission in the manifest.
|
||||
*
|
||||
* SAF hands back `content://` URIs, which SQLite cannot open — so nothing here
|
||||
* exposes a URI to Rust. Instead every operation copies bytes between the
|
||||
* chosen folder and an app-private staging path that Rust picks, and Rust only
|
||||
* ever does plain file I/O on its own private storage. That is sufficient
|
||||
* because the synced copy is never opened as a database; it is only ever
|
||||
* written whole or read whole.
|
||||
*/
|
||||
@TauriPlugin
|
||||
class SyncPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
companion object {
|
||||
private const val PREFS = "mtgc-sync"
|
||||
private const val KEY_TREE_URI = "tree-uri"
|
||||
|
||||
/** Generic type: a SQLite file has no useful registered MIME type. */
|
||||
private const val MIME = "application/octet-stream"
|
||||
|
||||
private const val NO_FOLDER =
|
||||
"No synced folder is set up yet — choose one first."
|
||||
}
|
||||
|
||||
private fun prefs() = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* The chosen folder, but only when the persisted grant is still held.
|
||||
*
|
||||
* A saved URI isn't proof of access: the user can revoke it in system
|
||||
* settings, and clearing app data drops it. Checking the live grant list
|
||||
* keeps us from failing later with an opaque SecurityException.
|
||||
*/
|
||||
private fun grantedTree(): Uri? {
|
||||
val saved = prefs().getString(KEY_TREE_URI, null) ?: return null
|
||||
val uri = Uri.parse(saved)
|
||||
val held = activity.contentResolver.persistedUriPermissions.any {
|
||||
it.uri == uri && it.isReadPermission && it.isWritePermission
|
||||
}
|
||||
return if (held) uri else null
|
||||
}
|
||||
|
||||
/** Best-effort human-readable name for the folder, for display in the UI. */
|
||||
private fun label(uri: Uri): String = try {
|
||||
val docId = DocumentsContract.getTreeDocumentId(uri)
|
||||
val parts = docId.split(":", limit = 2)
|
||||
if (parts.size == 2 && parts[0].equals("primary", ignoreCase = true)) {
|
||||
"Internal storage/${parts[1]}"
|
||||
} else {
|
||||
docId
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
uri.toString()
|
||||
}
|
||||
|
||||
private fun statusObject(): JSObject {
|
||||
val saved = prefs().getString(KEY_TREE_URI, null)
|
||||
val granted = grantedTree()
|
||||
return JSObject().apply {
|
||||
// "chosen but not granted" is a distinct, recoverable state worth
|
||||
// telling the user about — it means re-pick, not first-time setup.
|
||||
put("chosen", saved != null)
|
||||
put("granted", granted != null)
|
||||
put("label", saved?.let { label(Uri.parse(it)) })
|
||||
}
|
||||
}
|
||||
|
||||
@Command
|
||||
fun status(invoke: Invoke) {
|
||||
invoke.resolve(statusObject())
|
||||
}
|
||||
|
||||
@Command
|
||||
fun pickFolder(invoke: Invoke) {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
addFlags(
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
startActivityForResult(invoke, intent, "folderPicked")
|
||||
}
|
||||
|
||||
@ActivityCallback
|
||||
fun folderPicked(invoke: Invoke, result: ActivityResult) {
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode != Activity.RESULT_OK || uri == null) {
|
||||
// Backing out of the picker is a normal choice, not a failure.
|
||||
invoke.resolve(statusObject().apply { put("cancelled", true) })
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Without this the grant dies with the activity.
|
||||
activity.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Couldn't keep access to that folder: ${e.message}")
|
||||
return
|
||||
}
|
||||
prefs().edit().putString(KEY_TREE_URI, uri.toString()).apply()
|
||||
invoke.resolve(statusObject().apply { put("cancelled", false) })
|
||||
}
|
||||
|
||||
@Command
|
||||
fun forgetFolder(invoke: Invoke) {
|
||||
prefs().getString(KEY_TREE_URI, null)?.let {
|
||||
try {
|
||||
activity.contentResolver.releasePersistableUriPermission(
|
||||
Uri.parse(it),
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Already gone; clearing our own record below is what matters.
|
||||
}
|
||||
}
|
||||
prefs().edit().remove(KEY_TREE_URI).apply()
|
||||
invoke.resolve(statusObject())
|
||||
}
|
||||
|
||||
/** Locate a direct child of the chosen folder by display name. */
|
||||
private fun findChild(tree: Uri, name: String): Uri? {
|
||||
val children = DocumentsContract.buildChildDocumentsUriUsingTree(
|
||||
tree,
|
||||
DocumentsContract.getTreeDocumentId(tree)
|
||||
)
|
||||
val projection = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME
|
||||
)
|
||||
activity.contentResolver.query(children, projection, null, null, null)
|
||||
?.use { c: Cursor ->
|
||||
while (c.moveToNext()) {
|
||||
if (c.getString(1) == name) {
|
||||
return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of the files directly inside the chosen folder. Rust uses this to
|
||||
* discover the other devices' snapshots; which names matter is entirely its
|
||||
* business, so everything is returned unfiltered.
|
||||
*/
|
||||
@Command
|
||||
fun list(invoke: Invoke) {
|
||||
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
|
||||
try {
|
||||
val names = JSArray()
|
||||
val children = DocumentsContract.buildChildDocumentsUriUsingTree(
|
||||
tree,
|
||||
DocumentsContract.getTreeDocumentId(tree)
|
||||
)
|
||||
val projection = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||
activity.contentResolver.query(children, projection, null, null, null)
|
||||
?.use { c: Cursor ->
|
||||
while (c.moveToNext()) {
|
||||
c.getString(0)?.let { names.put(it) }
|
||||
}
|
||||
}
|
||||
invoke.resolve(JSObject().apply { put("names", names) })
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Couldn't list the synced folder: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from the chosen folder.
|
||||
*
|
||||
* Used only to clear away conflict copies once their contents have been
|
||||
* merged and republished — never to delete the library document itself.
|
||||
*/
|
||||
@Command
|
||||
fun deleteFile(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(NameArgs::class.java)
|
||||
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
|
||||
val doc = findChild(tree, args.name)
|
||||
if (doc == null) {
|
||||
invoke.resolve(JSObject().apply { put("removed", false) })
|
||||
return
|
||||
}
|
||||
try {
|
||||
val removed = DocumentsContract.deleteDocument(activity.contentResolver, doc)
|
||||
invoke.resolve(JSObject().apply { put("removed", removed) })
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Couldn't remove ${args.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy `name` out of the chosen folder into the app-private `path`. */
|
||||
@Command
|
||||
fun readInto(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(FileArgs::class.java)
|
||||
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
|
||||
val doc = findChild(tree, args.name)
|
||||
if (doc == null) {
|
||||
// A missing file is an expected state (nothing synced here yet), not an
|
||||
// error — the caller decides what to do about it.
|
||||
invoke.resolve(JSObject().apply {
|
||||
put("found", false)
|
||||
put("bytes", 0L)
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
val dest = File(args.path)
|
||||
dest.parentFile?.mkdirs()
|
||||
val copied = activity.contentResolver.openInputStream(doc)!!.use { input ->
|
||||
dest.outputStream().use { out -> input.copyTo(out) }
|
||||
}
|
||||
invoke.resolve(JSObject().apply {
|
||||
put("found", true)
|
||||
put("bytes", copied)
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Couldn't read ${args.name} from the synced folder: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy the app-private `path` into the chosen folder as `name`. */
|
||||
@Command
|
||||
fun writeFrom(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(FileArgs::class.java)
|
||||
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
|
||||
val src = File(args.path)
|
||||
if (!src.isFile) return invoke.reject("Nothing to upload at ${args.path}")
|
||||
try {
|
||||
// Overwrite the existing document when there is one, rather than
|
||||
// delete-and-recreate: a sync tool then sees one modified file instead
|
||||
// of a removal followed by an unrelated addition.
|
||||
val doc = findChild(tree, args.name)
|
||||
?: DocumentsContract.createDocument(
|
||||
activity.contentResolver,
|
||||
DocumentsContract.buildDocumentUriUsingTree(
|
||||
tree,
|
||||
DocumentsContract.getTreeDocumentId(tree)
|
||||
),
|
||||
MIME,
|
||||
args.name
|
||||
)
|
||||
?: return invoke.reject("Couldn't create ${args.name} in the synced folder")
|
||||
// "wt" truncates first. Plain "w" leaves any trailing bytes of a longer
|
||||
// previous file in place, which would silently corrupt a database that
|
||||
// got smaller.
|
||||
val copied = activity.contentResolver.openOutputStream(doc, "wt")!!.use { out ->
|
||||
src.inputStream().use { input -> input.copyTo(out) }
|
||||
}
|
||||
invoke.resolve(JSObject().apply { put("bytes", copied) })
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Couldn't write ${args.name} to the synced folder: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,11 @@ fn status_of(s: &AppState) -> Status {
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64);
|
||||
Status {
|
||||
library_path: s.library_path.display().to_string(),
|
||||
// Where the library document lives, not the internal working database —
|
||||
// that one is an implementation detail the user never sees. Always
|
||||
// populated: with nothing chosen it's the default in private storage,
|
||||
// so the path bar never goes blank.
|
||||
library_path: s.effective_document_path().display().to_string(),
|
||||
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),
|
||||
@@ -66,18 +70,6 @@ pub fn get_status(db: Db) -> Res<Status> {
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_library_path(db: Db, path: String) -> Res<Status> {
|
||||
let mut s = db.lock().unwrap();
|
||||
s.set_library_path(PathBuf::from(path));
|
||||
// Best-effort reopen: the chosen path must stick (and be reported back)
|
||||
// even if the reference db can't currently be attached (e.g. stale
|
||||
// schema pending an "Update card data" run) — that's a separate concern
|
||||
// from which library file is open.
|
||||
let _ = s.reopen();
|
||||
Ok(status_of(&s))
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod commands;
|
||||
mod state;
|
||||
mod sync;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -31,10 +32,27 @@ pub(crate) fn logged<T>(what: &str, result: Result<T, String>) -> Result<T, Stri
|
||||
result
|
||||
}
|
||||
|
||||
/// Register the Kotlin-side `SyncPlugin` and stash its handle in app state, so
|
||||
/// the sync commands can call across to SAF. See `sync.rs` for why library sync
|
||||
/// goes through a Java plugin rather than plain `std::fs`.
|
||||
#[cfg(target_os = "android")]
|
||||
fn sync_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||
tauri::plugin::Builder::new("mtgc-sync")
|
||||
.setup(|app, api| {
|
||||
let handle = api.register_android_plugin("com.garrettmills.mtgc", "SyncPlugin")?;
|
||||
app.manage(sync::SyncBridge(handle));
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
let builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init());
|
||||
#[cfg(target_os = "android")]
|
||||
let builder = builder.plugin(sync_plugin());
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
let data_dir = app
|
||||
.path()
|
||||
@@ -47,10 +65,15 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
commands::set_library_path,
|
||||
commands::update_card_data,
|
||||
commands::cancel_update_card_data,
|
||||
commands::import_reference_db,
|
||||
sync::sync_status,
|
||||
sync::sync_pick_folder,
|
||||
sync::sync_set_document,
|
||||
sync::sync_forget_document,
|
||||
sync::sync_now,
|
||||
sync::convert_legacy_library,
|
||||
commands::search_cards,
|
||||
commands::add_owned,
|
||||
commands::set_owned,
|
||||
|
||||
@@ -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);
|
||||
|
||||
744
src-tauri/src/sync.rs
Normal file
744
src-tauri/src/sync.rs
Normal file
@@ -0,0 +1,744 @@
|
||||
//! Reading and writing the user's library document, wherever they keep it.
|
||||
//!
|
||||
//! The reconciliation itself lives in [`mtgc_core::sync`] as a pure function.
|
||||
//! This module is only the plumbing: getting the document in and out of a
|
||||
//! folder that differs by platform, and running the cycle in the right order.
|
||||
//!
|
||||
//! Desktop points at an ordinary file. Android can't: the working database must
|
||||
//! stay in app-private storage (SQLite can't open a `content://` URI), and the
|
||||
//! user's folder is reachable only through the Storage Access Framework. So on
|
||||
//! Android every transfer is staged through an app-private file that Rust owns
|
||||
//! and the Kotlin side copies to or from — deliberately avoiding
|
||||
//! `MANAGE_EXTERNAL_STORAGE`, which would hand the app the entire shared volume
|
||||
//! just to reach one folder. [`Folder`] hides that split.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use mtgc_core::sync::{self as core_sync, LibraryDoc, MergeReport};
|
||||
use serde::Serialize;
|
||||
#[cfg(target_os = "android")]
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
type Db<'a> = tauri::State<'a, std::sync::Mutex<AppState>>;
|
||||
|
||||
/// Document name used on Android, where the user grants a folder rather than
|
||||
/// choosing a file. Desktop uses whatever they named their file.
|
||||
#[cfg(target_os = "android")]
|
||||
const ANDROID_DOC_NAME: &str = "library.json";
|
||||
|
||||
/// Where transfers are staged across the SAF boundary. Inside the app data dir,
|
||||
/// so it inherits the same private-storage guarantees as the working copy.
|
||||
/// Android-only: desktop reads and writes the document directly.
|
||||
#[cfg(target_os = "android")]
|
||||
fn staging_path(data_dir: &Path, name: &str) -> PathBuf {
|
||||
data_dir.join("sync-staging").join(name)
|
||||
}
|
||||
|
||||
fn base_path(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("sync-base.json")
|
||||
}
|
||||
|
||||
/// State of the library document, as shown in the UI.
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct SyncStatus {
|
||||
/// Whether a document has been chosen at all.
|
||||
pub chosen: bool,
|
||||
/// Whether it's currently reachable. Distinct from `chosen`: an Android SAF
|
||||
/// grant can be revoked and a desktop folder can be deleted or unmounted,
|
||||
/// both of which need re-picking rather than first-time setup.
|
||||
pub granted: bool,
|
||||
pub label: Option<String>,
|
||||
/// True where the folder is chosen through the system picker (Android)
|
||||
/// rather than by path — the one thing the UI branches on.
|
||||
pub picker_based: bool,
|
||||
/// A pre-document `library.db` waiting to be converted, if any.
|
||||
pub legacy_library: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod bridge {
|
||||
use super::*;
|
||||
|
||||
/// Handle on the Kotlin-side `SyncPlugin`, put into Tauri's state store
|
||||
/// during plugin setup (see `lib.rs`).
|
||||
pub struct SyncBridge(pub tauri::plugin::PluginHandle<tauri::Wry>);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FileArgs<'a> {
|
||||
name: &'a str,
|
||||
path: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct NameArgs<'a> {
|
||||
name: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReadResult {
|
||||
found: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListResult {
|
||||
#[serde(default)]
|
||||
names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct FolderStatus {
|
||||
#[serde(default)]
|
||||
pub chosen: bool,
|
||||
#[serde(default)]
|
||||
pub granted: bool,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
fn call<T: serde::de::DeserializeOwned>(
|
||||
app: &AppHandle,
|
||||
command: &str,
|
||||
payload: impl Serialize,
|
||||
) -> Result<T, String> {
|
||||
let bridge = app
|
||||
.try_state::<SyncBridge>()
|
||||
.ok_or_else(|| "Sync plugin failed to load — see the logs.".to_string())?;
|
||||
bridge
|
||||
.0
|
||||
.run_mobile_plugin::<T>(command, payload)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn status(app: &AppHandle) -> Result<FolderStatus, String> {
|
||||
call(app, "status", ())
|
||||
}
|
||||
|
||||
pub fn pick_folder(app: &AppHandle) -> Result<FolderStatus, String> {
|
||||
call(app, "pickFolder", ())
|
||||
}
|
||||
|
||||
pub fn forget_folder(app: &AppHandle) -> Result<FolderStatus, String> {
|
||||
call(app, "forgetFolder", ())
|
||||
}
|
||||
|
||||
pub fn list(app: &AppHandle) -> Result<Vec<String>, String> {
|
||||
Ok(call::<ListResult>(app, "list", ())?.names)
|
||||
}
|
||||
|
||||
pub fn read_into(app: &AppHandle, name: &str, path: &Path) -> Result<bool, String> {
|
||||
let res: ReadResult = call(
|
||||
app,
|
||||
"readInto",
|
||||
FileArgs { name, path: &path.display().to_string() },
|
||||
)?;
|
||||
Ok(res.found)
|
||||
}
|
||||
|
||||
pub fn write_from(app: &AppHandle, name: &str, path: &Path) -> Result<(), String> {
|
||||
let _: serde_json::Value = call(
|
||||
app,
|
||||
"writeFrom",
|
||||
FileArgs { name, path: &path.display().to_string() },
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(app: &AppHandle, name: &str) -> Result<(), String> {
|
||||
let _: serde_json::Value = call(app, "deleteFile", NameArgs { name })?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub use bridge::SyncBridge;
|
||||
|
||||
/// The folder as an ordinary directory — desktop's whole implementation. Free
|
||||
/// functions so they stay callable, and testable, without a running app.
|
||||
mod plain {
|
||||
use std::path::Path;
|
||||
|
||||
pub fn list(dir: &Path) -> Result<Vec<String>, String> {
|
||||
let entries =
|
||||
std::fs::read_dir(dir).map_err(|e| format!("Can't read {}: {e}", dir.display()))?;
|
||||
let mut names = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
names.push(entry.file_name().to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
pub fn read(dir: &Path, name: &str) -> Result<Option<String>, String> {
|
||||
let path = dir.join(name);
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
std::fs::read_to_string(&path)
|
||||
.map(Some)
|
||||
.map_err(|e| format!("Can't read {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub fn write(dir: &Path, name: &str, text: &str) -> Result<(), String> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
// Write beside the target and rename, so whatever is watching the
|
||||
// folder never observes a half-written document.
|
||||
let tmp = dir.join(format!("{name}.tmp"));
|
||||
std::fs::write(&tmp, text)
|
||||
.map_err(|e| format!("Can't write in {}: {e}", dir.display()))?;
|
||||
std::fs::rename(&tmp, dir.join(name)).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
format!("Can't save {name} in {}: {e}", dir.display())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete(dir: &Path, name: &str) -> Result<(), String> {
|
||||
std::fs::remove_file(dir.join(name))
|
||||
.map_err(|e| format!("Can't remove {name}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the library document lives, however this platform reaches it.
|
||||
struct Location {
|
||||
folder: Folder,
|
||||
doc_name: String,
|
||||
}
|
||||
|
||||
enum Folder {
|
||||
#[cfg(target_os = "android")]
|
||||
Picker,
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
impl Location {
|
||||
fn list(&self, #[allow(unused_variables)] app: &AppHandle) -> Result<Vec<String>, String> {
|
||||
match &self.folder {
|
||||
#[cfg(target_os = "android")]
|
||||
Folder::Picker => bridge::list(app),
|
||||
Folder::Path(dir) => plain::list(dir),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a document by name. `Ok(None)` means it isn't there — a normal
|
||||
/// state (nothing published yet), not a failure.
|
||||
fn read(
|
||||
&self,
|
||||
#[allow(unused_variables)] app: &AppHandle,
|
||||
#[allow(unused_variables)] data_dir: &Path,
|
||||
name: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
match &self.folder {
|
||||
#[cfg(target_os = "android")]
|
||||
Folder::Picker => {
|
||||
let staged = staging_path(data_dir, "incoming.json");
|
||||
if !bridge::read_into(app, name, &staged)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = std::fs::read_to_string(&staged).map_err(|e| e.to_string())?;
|
||||
let _ = std::fs::remove_file(&staged);
|
||||
Ok(Some(text))
|
||||
}
|
||||
Folder::Path(dir) => plain::read(dir, name),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(
|
||||
&self,
|
||||
#[allow(unused_variables)] app: &AppHandle,
|
||||
#[allow(unused_variables)] data_dir: &Path,
|
||||
text: &str,
|
||||
) -> Result<(), String> {
|
||||
match &self.folder {
|
||||
#[cfg(target_os = "android")]
|
||||
Folder::Picker => {
|
||||
let staged = staging_path(data_dir, "outgoing.json");
|
||||
if let Some(parent) = staged.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&staged, text).map_err(|e| e.to_string())?;
|
||||
let result = bridge::write_from(app, &self.doc_name, &staged);
|
||||
let _ = std::fs::remove_file(&staged);
|
||||
result
|
||||
}
|
||||
Folder::Path(dir) => plain::write(dir, &self.doc_name, text),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(&self, #[allow(unused_variables)] app: &AppHandle, name: &str) -> Result<(), String> {
|
||||
match &self.folder {
|
||||
#[cfg(target_os = "android")]
|
||||
Folder::Picker => bridge::delete(app, name),
|
||||
Folder::Path(dir) => plain::delete(dir, name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Names of files that look like conflict copies of our document — what
|
||||
/// sync tools leave behind when two devices publish while offline.
|
||||
///
|
||||
/// Matched by shape (same stem, `.json`) rather than by any tool's naming
|
||||
/// convention, so it works for Syncthing, Dropbox, Nextcloud and the rest
|
||||
/// without knowing about any of them.
|
||||
fn conflict_copies(&self, app: &AppHandle) -> Vec<String> {
|
||||
conflict_copies_of(&self.doc_name, self.list(app).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the conflict copies of `doc_name` out of a directory listing.
|
||||
///
|
||||
/// Split out from [`Location::conflict_copies`] so the matching rule — the part
|
||||
/// with all the edge cases — is testable without a running app.
|
||||
fn conflict_copies_of(doc_name: &str, names: Vec<String>) -> Vec<String> {
|
||||
let stem = doc_name.strip_suffix(".json").unwrap_or(doc_name);
|
||||
let mut found: Vec<String> = names
|
||||
.into_iter()
|
||||
.filter(|n| {
|
||||
n != doc_name
|
||||
&& n.starts_with(stem)
|
||||
&& n.ends_with(".json")
|
||||
// Our own atomic-write temporary, if a previous run died
|
||||
// between writing and renaming.
|
||||
&& !n.ends_with(".json.tmp")
|
||||
})
|
||||
.collect();
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
/// Resolve where the document lives.
|
||||
///
|
||||
/// Never fails for want of a choice: with nothing picked, the document sits in
|
||||
/// app-private storage, so the library is always saved somewhere. Choosing a
|
||||
/// synced location is a preference on top of that.
|
||||
fn locate(#[allow(unused_variables)] app: &AppHandle, db: &Db) -> Result<Location, String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let status = bridge::status(app)?;
|
||||
if status.chosen && !status.granted {
|
||||
// Silently falling back to private storage here would look like the
|
||||
// library had been emptied, so this is worth surfacing.
|
||||
return Err("Access to your library folder was lost — choose it again.".into());
|
||||
}
|
||||
if status.granted {
|
||||
return Ok(Location {
|
||||
folder: Folder::Picker,
|
||||
doc_name: ANDROID_DOC_NAME.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let path = db.lock().unwrap().effective_document_path();
|
||||
let dir = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("{} has no folder.", path.display()))?
|
||||
.to_path_buf();
|
||||
let doc_name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("{} isn't a file name.", path.display()))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
Ok(Location { folder: Folder::Path(dir), doc_name })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sync_status(
|
||||
#[allow(unused_variables)] app: AppHandle,
|
||||
db: Db,
|
||||
) -> Result<SyncStatus, String> {
|
||||
let legacy_library = db
|
||||
.lock()
|
||||
.unwrap()
|
||||
.legacy_library_path()
|
||||
.map(|p| p.display().to_string());
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let s = bridge::status(&app)?;
|
||||
if s.chosen {
|
||||
return Ok(SyncStatus {
|
||||
chosen: true,
|
||||
granted: s.granted,
|
||||
label: s.label,
|
||||
picker_based: true,
|
||||
legacy_library,
|
||||
});
|
||||
}
|
||||
// No folder picked: the library is in private storage and works, it
|
||||
// just isn't synced anywhere yet.
|
||||
return Ok(SyncStatus {
|
||||
chosen: false,
|
||||
granted: true,
|
||||
label: Some(
|
||||
db.lock()
|
||||
.unwrap()
|
||||
.effective_document_path()
|
||||
.display()
|
||||
.to_string(),
|
||||
),
|
||||
picker_based: true,
|
||||
legacy_library,
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let s = db.lock().unwrap();
|
||||
let path = s.effective_document_path();
|
||||
Ok(SyncStatus {
|
||||
chosen: s.document_path().is_some(),
|
||||
// The folder can be gone — deleted, or on a drive that isn't
|
||||
// mounted. The document itself needn't exist: sync creates it.
|
||||
granted: path.parent().map(Path::is_dir).unwrap_or(false),
|
||||
label: Some(path.display().to_string()),
|
||||
picker_based: false,
|
||||
legacy_library,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Android only: grant access to a folder through the system picker.
|
||||
#[tauri::command]
|
||||
pub fn sync_pick_folder(
|
||||
#[allow(unused_variables)] app: AppHandle,
|
||||
db: Db,
|
||||
) -> Result<SyncStatus, String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
crate::logged("sync_pick_folder", bridge::pick_folder(&app))?;
|
||||
sync_status(app, db)
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let _ = (app, db);
|
||||
Err("This platform chooses the library file by path.".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop only: adopt a library document the frontend picked.
|
||||
#[tauri::command]
|
||||
pub fn sync_set_document(
|
||||
app: AppHandle,
|
||||
db: Db,
|
||||
#[allow(unused_variables)] path: String,
|
||||
) -> Result<SyncStatus, String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let _ = (&app, &db, path);
|
||||
Err("This platform chooses the library folder through the system picker.".into())
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
let mut path = PathBuf::from(path);
|
||||
if path.extension().is_none() {
|
||||
path.set_extension("json");
|
||||
}
|
||||
match path.parent() {
|
||||
Some(dir) if dir.is_dir() => {}
|
||||
_ => return Err(format!("{} isn't in a folder that exists.", path.display())),
|
||||
}
|
||||
|
||||
// Two very different intents share this one command, told apart by
|
||||
// whether the target already holds a library:
|
||||
//
|
||||
// * an existing document — "open that library", so the working copy
|
||||
// is dropped and rebuilt from it;
|
||||
// * a new file — "keep my library here from now on", so the working
|
||||
// copy is *kept* and published into it.
|
||||
//
|
||||
// Getting this wrong destroys data: always resetting would discard
|
||||
// everything the user had whenever they pointed at a fresh file.
|
||||
let adopting = path.is_file();
|
||||
db.lock().unwrap().set_document_path(Some(path));
|
||||
if adopting {
|
||||
reset_working_copy(&app, &db)?;
|
||||
} else {
|
||||
// Clear only the merge base, so the new file is seen as empty and
|
||||
// the whole current library gets published into it.
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let _ = std::fs::remove_file(base_path(&data_dir));
|
||||
}
|
||||
// Either way, get the document and the working copy agreeing now rather
|
||||
// than at some later trigger.
|
||||
if let Err(e) = sync_now(app.clone(), db.clone()) {
|
||||
crate::mlog!("sync_set_document: initial sync failed: {e}");
|
||||
}
|
||||
sync_status(app, db)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sync_forget_document(app: AppHandle, db: Db) -> Result<SyncStatus, String> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
crate::logged("sync_forget_document", bridge::forget_folder(&app))?;
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
db.lock().unwrap().set_document_path(None);
|
||||
}
|
||||
sync_status(app, db)
|
||||
}
|
||||
|
||||
/// Empty the working copy and forget the merge base, so the next sync adopts
|
||||
/// whatever the document says from scratch.
|
||||
fn reset_working_copy(app: &AppHandle, db: &Db) -> Result<(), String> {
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let _ = std::fs::remove_file(base_path(&data_dir));
|
||||
|
||||
let mut s = db.lock().unwrap();
|
||||
let working = s.working_db_path.clone();
|
||||
// Close first: the file can't be replaced while the catalog holds it open.
|
||||
s.close();
|
||||
let _ = std::fs::remove_file(&working);
|
||||
if let Err(e) = s.reopen() {
|
||||
crate::mlog!("reset_working_copy: reopen failed: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Outcome of one sync.
|
||||
#[derive(Serialize, Default)]
|
||||
pub struct SyncReport {
|
||||
pub changed: bool,
|
||||
pub published: bool,
|
||||
/// Conflict copies absorbed and cleaned up, if any.
|
||||
pub absorbed: usize,
|
||||
pub report: MergeReport,
|
||||
}
|
||||
|
||||
/// Reconcile the working copy with the library document.
|
||||
///
|
||||
/// `Ok(None)` when a sync was already in flight and this call stood down. Sync
|
||||
/// runs automatically on launch and on every foreground/background transition,
|
||||
/// so overlap is routine rather than exceptional.
|
||||
#[tauri::command]
|
||||
pub fn sync_now(app: AppHandle, db: Db) -> Result<Option<SyncReport>, String> {
|
||||
{
|
||||
let mut state = db.lock().unwrap();
|
||||
if state.sync_running {
|
||||
return Ok(None);
|
||||
}
|
||||
state.sync_running = true;
|
||||
}
|
||||
let result = sync_inner(&app, &db);
|
||||
db.lock().unwrap().sync_running = false;
|
||||
result.map(Some)
|
||||
}
|
||||
|
||||
fn sync_inner(app: &AppHandle, db: &Db) -> Result<SyncReport, String> {
|
||||
let location = locate(app, db)?;
|
||||
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
let working = db.lock().unwrap().working_db_path.clone();
|
||||
|
||||
// 1. Read the document. Absent simply means nothing has been written yet —
|
||||
// the file the user named gets created by the first sync.
|
||||
let existing = location.read(app, &data_dir, &location.doc_name)?;
|
||||
let doc_exists = existing.is_some();
|
||||
let mut theirs = match existing {
|
||||
Some(text) => LibraryDoc::from_json(&text).map_err(|e| e.to_string())?,
|
||||
None => LibraryDoc::default(),
|
||||
};
|
||||
|
||||
// 2. Absorb any conflict copies, so nothing is stranded in one.
|
||||
let base = read_base(&data_dir);
|
||||
let copies = location.conflict_copies(app);
|
||||
let mut absorbed = Vec::new();
|
||||
for name in &copies {
|
||||
let Ok(Some(text)) = location.read(app, &data_dir, name) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(copy) = LibraryDoc::from_json(&text) else {
|
||||
continue; // Not one of ours, or truncated mid-sync; leave it alone.
|
||||
};
|
||||
let (merged, _) = core_sync::merge3(&base, &theirs, ©);
|
||||
theirs = merged;
|
||||
absorbed.push(name.clone());
|
||||
}
|
||||
|
||||
// 3-4. Merge our edits with theirs and make the working copy match.
|
||||
let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?;
|
||||
let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?;
|
||||
let (merged, report) = core_sync::merge3(&base, &ours, &theirs);
|
||||
if merged != ours {
|
||||
core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// 5. Publish only when we have something to contribute, or the file the
|
||||
// user named doesn't exist yet — otherwise every sync would look like a
|
||||
// modification to whatever is watching the folder.
|
||||
let published = merged != theirs || !doc_exists;
|
||||
if published {
|
||||
let text = merged.to_json().map_err(|e| e.to_string())?;
|
||||
location.write(app, &data_dir, &text)?;
|
||||
}
|
||||
|
||||
// Only now that their content is safely in the published document is it
|
||||
// safe to remove the copies it came from.
|
||||
if published {
|
||||
for name in &absorbed {
|
||||
if let Err(e) = location.delete(app, name) {
|
||||
crate::mlog!("sync: couldn't remove absorbed copy {name}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Remember the document *as read*, never the merged result. If the write
|
||||
// above was lost, the remote still matches this base, so the next sync sees
|
||||
// our edits as ours and republishes them instead of reading their absence
|
||||
// from the document as a deletion.
|
||||
write_base(&data_dir, &theirs);
|
||||
|
||||
crate::mlog!(
|
||||
"sync_now: changed={} published={} absorbed={} conflicts={}",
|
||||
!report.is_empty(),
|
||||
published,
|
||||
absorbed.len(),
|
||||
report.conflicts.len()
|
||||
);
|
||||
|
||||
Ok(SyncReport {
|
||||
changed: !report.is_empty(),
|
||||
published,
|
||||
absorbed: absorbed.len(),
|
||||
report,
|
||||
})
|
||||
}
|
||||
|
||||
/// The document as last read. Missing or unreadable yields an empty document,
|
||||
/// which is the safe default: with no ancestor, a merge never deletes anything.
|
||||
fn read_base(data_dir: &Path) -> LibraryDoc {
|
||||
std::fs::read_to_string(base_path(data_dir))
|
||||
.ok()
|
||||
.and_then(|t| LibraryDoc::from_json(&t).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn write_base(data_dir: &Path, doc: &LibraryDoc) {
|
||||
if let Ok(text) = doc.to_json() {
|
||||
let _ = std::fs::write(base_path(data_dir), text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a pre-document `library.db` into the current format, merging it into
|
||||
/// the working copy so the next sync publishes it.
|
||||
#[tauri::command]
|
||||
pub fn convert_legacy_library(app: AppHandle, db: Db, path: String) -> Result<SyncReport, String> {
|
||||
let legacy = PathBuf::from(&path);
|
||||
if !legacy.is_file() {
|
||||
return Err(format!("{} isn't there.", legacy.display()));
|
||||
}
|
||||
let doc = crate::logged(
|
||||
"convert_legacy_library",
|
||||
mtgc_core::sync::convert_legacy_library(&legacy).map_err(|e| e.to_string()),
|
||||
)?;
|
||||
|
||||
let working = db.lock().unwrap().working_db_path.clone();
|
||||
let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?;
|
||||
let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?;
|
||||
// Merged rather than applied outright, so converting doesn't discard
|
||||
// anything already in the working copy. With an empty ancestor nothing is
|
||||
// treated as a deletion.
|
||||
let (merged, report) = core_sync::merge3(&LibraryDoc::default(), &ours, &doc);
|
||||
core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?;
|
||||
|
||||
db.lock().unwrap().clear_legacy_library();
|
||||
crate::mlog!(
|
||||
"convert_legacy_library: {} cards, {} decks from {}",
|
||||
doc.cards.len(),
|
||||
doc.decks.len(),
|
||||
legacy.display()
|
||||
);
|
||||
|
||||
// Publish immediately when a document is already configured, so the
|
||||
// converted library reaches the other devices without waiting.
|
||||
if locate(&app, &db).is_ok() {
|
||||
if let Some(r) = sync_now(app, db)? {
|
||||
return Ok(r);
|
||||
}
|
||||
}
|
||||
Ok(SyncReport {
|
||||
changed: !report.is_empty(),
|
||||
published: false,
|
||||
absorbed: 0,
|
||||
report,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("mtgc-doc-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_replaces_the_document_and_leaves_no_temporary() {
|
||||
// A stray "<name>.tmp" would be picked up as a conflict copy and, worse,
|
||||
// synced as a partial document.
|
||||
let dir = scratch("write");
|
||||
plain::write(&dir, "library.json", "{\"version\":1}").unwrap();
|
||||
plain::write(&dir, "library.json", "{}").unwrap();
|
||||
|
||||
assert_eq!(plain::read(&dir, "library.json").unwrap().unwrap(), "{}");
|
||||
assert_eq!(plain::list(&dir).unwrap(), vec!["library.json"]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_a_document_that_isnt_there_is_not_an_error() {
|
||||
// Normal before anything has been published.
|
||||
let dir = scratch("missing");
|
||||
assert_eq!(plain::read(&dir, "library.json").unwrap(), None);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_copies_are_matched_by_shape_not_by_tool() {
|
||||
let dir = scratch("conflicts");
|
||||
for name in [
|
||||
"library.json",
|
||||
"library.sync-conflict-20260803-120000-ABCDEFG.json", // Syncthing
|
||||
"library (SFConflict garrettmills 2026-08-03).json", // Seafile
|
||||
"library (conflicted copy 2026-08-03).json", // Dropbox
|
||||
"library.json.tmp", // our own, mid-write
|
||||
"notes.json", // unrelated
|
||||
"reference.db", // unrelated
|
||||
] {
|
||||
std::fs::write(dir.join(name), "{}").unwrap();
|
||||
}
|
||||
|
||||
let found = conflict_copies_of("library.json", plain::list(&dir).unwrap());
|
||||
|
||||
assert_eq!(
|
||||
found,
|
||||
vec![
|
||||
"library (SFConflict garrettmills 2026-08-03).json",
|
||||
"library (conflicted copy 2026-08-03).json",
|
||||
"library.sync-conflict-20260803-120000-ABCDEFG.json",
|
||||
],
|
||||
"should catch every tool's naming, and nothing else"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_differently_named_document_only_claims_its_own_copies() {
|
||||
// The user names their own file on desktop, so the rule has to key off
|
||||
// that name rather than assuming "library".
|
||||
let found = conflict_copies_of(
|
||||
"Decks.json",
|
||||
vec![
|
||||
"Decks.json".into(),
|
||||
"Decks (conflicted copy).json".into(),
|
||||
"library.sync-conflict-1.json".into(),
|
||||
"Decks-backup.txt".into(),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(found, vec!["Decks (conflicted copy).json"]);
|
||||
}
|
||||
}
|
||||
357
src/App.tsx
357
src/App.tsx
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status } from "./api";
|
||||
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api";
|
||||
import {
|
||||
ColorDistributionBar,
|
||||
HoverPreviewProvider,
|
||||
@@ -218,12 +218,25 @@ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: n
|
||||
// so missing the snackbar isn't the last chance to undo.
|
||||
const UNDO_SNACKBAR_MS = 8000;
|
||||
|
||||
/** How often an open, visible window re-syncs. Long enough not to churn the
|
||||
* synced folder, short enough that a second device sees edits without being
|
||||
* restarted. Launch and foreground/background transitions sync too. */
|
||||
const AUTO_SYNC_MS = 3 * 60 * 1000;
|
||||
|
||||
export default function App() {
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [libStats, setLibStats] = useState<LibraryStats | null>(null);
|
||||
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
|
||||
const [progress, setProgress] = useState<IngestProgress | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Synced-folder grant for library sync. Only fetched when the Info tab is
|
||||
// open: the grant can be revoked from system settings while the app is
|
||||
// backgrounded, so a value cached at startup would be stale exactly when the
|
||||
// user comes back to check it.
|
||||
const [sync, setSync] = useState<SyncStatus | null>(null);
|
||||
const [syncBusy, setSyncBusy] = useState(false);
|
||||
// Result of the last round-trip self-test, shown until the next action.
|
||||
const [syncTest, setSyncTest] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const lastProgress = useRef<IngestProgress | null>(null);
|
||||
// Set by the ingest-progress listener below; reset before each
|
||||
// updateCardData() call so it can detect a stalled invoke — see there.
|
||||
@@ -328,6 +341,13 @@ export default function App() {
|
||||
// invited exactly what happened: a redundant download click before the
|
||||
// real status had even loaded. Distinguish the two explicitly.
|
||||
const statusLoaded = status !== null;
|
||||
// Mirrors `ready` for the automatic-sync callbacks, which must stay stable
|
||||
// across renders (re-creating them would re-arm the listeners below and fire
|
||||
// a fresh sync every time card data finished loading).
|
||||
const readyRef = useRef(ready);
|
||||
useEffect(() => {
|
||||
readyRef.current = ready;
|
||||
}, [ready]);
|
||||
|
||||
// Keep the sidebar's library summary fresh: fetch once card data is ready,
|
||||
// then poll (cards/decks can change from any tab without a shared event bus).
|
||||
@@ -405,16 +425,215 @@ export default function App() {
|
||||
await api.cancelUpdateCardData();
|
||||
}
|
||||
|
||||
async function switchLibrary(path: string) {
|
||||
|
||||
/** Refresh the synced-folder grant state. Never throws: "no folder chosen" is
|
||||
* a normal state, reported in the panel rather than as an error. */
|
||||
const refreshSync = useCallback(async () => {
|
||||
setSyncBusy(true);
|
||||
try {
|
||||
setStatus(await api.setLibraryPath(path));
|
||||
setSync(await api.syncStatus());
|
||||
} catch (err) {
|
||||
setProgress({
|
||||
stage: "error",
|
||||
message: `Couldn't open that library: ${String(err)}`,
|
||||
overall_percent: 0,
|
||||
stages: lastProgress.current?.stages ?? [],
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Once at startup: the automatic sync below needs to know whether a folder is
|
||||
// configured at all.
|
||||
useEffect(() => {
|
||||
if (statusLoaded) refreshSync();
|
||||
}, [statusLoaded, refreshSync]);
|
||||
|
||||
// And whenever the Info tab is opened, which is also when the user gets back
|
||||
// from the folder picker, and when a revoked grant would first matter.
|
||||
useEffect(() => {
|
||||
if (tab === "info") refreshSync();
|
||||
}, [tab, refreshSync]);
|
||||
|
||||
|
||||
/** Choose where the library lives. Android grants a folder through the system
|
||||
* picker (SAF has no path to hand back); desktop picks the JSON file itself. */
|
||||
async function pickLibrary() {
|
||||
setSyncTest(null);
|
||||
try {
|
||||
if (sync?.picker_based) {
|
||||
setSyncBusy(true);
|
||||
setSync(await api.syncPickFolder());
|
||||
return;
|
||||
}
|
||||
const path = await open({
|
||||
title: "Choose your library file",
|
||||
// Start where the library currently lives, so the dialog opens
|
||||
// somewhere useful rather than at an arbitrary default.
|
||||
defaultPath: sync?.label ?? undefined,
|
||||
filters: [
|
||||
{ name: "mtgc library", extensions: ["json"] },
|
||||
{ name: "All files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (typeof path !== "string") return;
|
||||
setSyncBusy(true);
|
||||
setSync(await api.syncSetDocument(path));
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Desktop: start a new library file, e.g. inside a synced folder. */
|
||||
async function newLibrary() {
|
||||
setSyncTest(null);
|
||||
try {
|
||||
const path = await save({
|
||||
title: "Save your library here (put it in a synced folder)",
|
||||
defaultPath: "mtgc-library.json",
|
||||
filters: [{ name: "mtgc library", extensions: ["json"] }],
|
||||
});
|
||||
if (typeof path !== "string") return;
|
||||
setSyncBusy(true);
|
||||
// Pointing at a file that doesn't exist yet moves the current library
|
||||
// there rather than starting empty — see sync_set_document.
|
||||
setSync(await api.syncSetDocument(path));
|
||||
setSyncTest({ ok: true, message: `Your library is now kept at ${path}.` });
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function forgetLibrary() {
|
||||
setSyncBusy(true);
|
||||
setSyncTest(null);
|
||||
try {
|
||||
setSync(await api.syncForgetDocument());
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert a library.db from an older build into the current format. */
|
||||
async function convertLegacy(path: string) {
|
||||
setSyncBusy(true);
|
||||
setSyncTest(null);
|
||||
try {
|
||||
const r = await api.convertLegacyLibrary(path);
|
||||
const n = r.report.cards_changed + r.report.decks_changed;
|
||||
const status = await api.syncStatus();
|
||||
setSync(status);
|
||||
// Say where it landed — otherwise a successful conversion looks like
|
||||
// nothing happened.
|
||||
setSyncTest({
|
||||
ok: true,
|
||||
message: `Converted your old library (${n} entries). It's now kept at ${status.label ?? "your library file"}.`,
|
||||
});
|
||||
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
|
||||
setDeckRefreshSignal((v) => v + 1);
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge the library document with local edits and publish the result.
|
||||
*
|
||||
* Shared by the Info tab's button and the automatic triggers below. Returns
|
||||
* the report so the caller can decide whether to say anything; throws only
|
||||
* what the backend threw. */
|
||||
const runSync = useCallback(async () => {
|
||||
const report = await api.syncNow();
|
||||
if (!report) return null; // Another sync had it in hand.
|
||||
const changed = report.changed;
|
||||
if (changed) {
|
||||
// Library and deck views hold fetched copies; a merge changes rows
|
||||
// underneath them, so pull them back into line rather than waiting for
|
||||
// the summary's 5s poll. Needs card data loaded, hence the guard.
|
||||
if (readyRef.current) {
|
||||
api.getLibraryStats().then(setLibStats).catch(() => {});
|
||||
}
|
||||
setDeckRefreshSignal((v) => v + 1);
|
||||
}
|
||||
return { report, changed };
|
||||
}, []);
|
||||
|
||||
// Automatic sync. Runs once the folder is usable, then on every trip to or
|
||||
// from the background: leaving publishes what changed here, returning picks
|
||||
// up what changed elsewhere. Keyed on `granted` alone so merely re-checking
|
||||
// the folder's status doesn't re-arm the listeners and resync.
|
||||
const syncGranted = sync?.granted ?? false;
|
||||
useEffect(() => {
|
||||
if (!syncGranted) return;
|
||||
let cancelled = false;
|
||||
|
||||
// Failures here are deliberately silent: the folder can be temporarily
|
||||
// unavailable (drive unmounted, grant revoked), and nagging on every app
|
||||
// switch would be worse than the Info tab reporting it on demand. Nothing
|
||||
// is lost by a skipped run — the merge base only advances to a document we
|
||||
// actually read, so a lost publish is simply re-detected next time.
|
||||
const run = () => {
|
||||
if (cancelled) return;
|
||||
runSync().catch(() => {});
|
||||
};
|
||||
|
||||
run();
|
||||
document.addEventListener("visibilitychange", run);
|
||||
// Visibility changes alone would strand a desktop session that simply stays
|
||||
// open: edits made after launch would never reach the other devices. Poll
|
||||
// while visible so an always-open window still publishes. Skipped while
|
||||
// hidden — the transition back handles that case.
|
||||
const timer = setInterval(() => {
|
||||
if (!document.hidden) run();
|
||||
}, AUTO_SYNC_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", run);
|
||||
};
|
||||
}, [syncGranted, runSync]);
|
||||
|
||||
/** The Info tab's explicit "Sync now" — same work, but reports the outcome. */
|
||||
async function syncNow() {
|
||||
setSyncBusy(true);
|
||||
setSyncTest(null);
|
||||
try {
|
||||
const result = await runSync();
|
||||
if (!result) {
|
||||
setSyncTest({ ok: true, message: "A sync was already running." });
|
||||
return;
|
||||
}
|
||||
const { report, changed } = result;
|
||||
const parts: string[] = [];
|
||||
if (changed) {
|
||||
const r = report.report;
|
||||
const bits = [
|
||||
r.cards_changed && `${r.cards_changed} card${r.cards_changed === 1 ? "" : "s"}`,
|
||||
r.decks_changed && `${r.decks_changed} deck${r.decks_changed === 1 ? "" : "s"}`,
|
||||
r.decks_removed && `${r.decks_removed} removed`,
|
||||
].filter(Boolean);
|
||||
parts.push(`Merged ${bits.join(", ")}.`);
|
||||
} else {
|
||||
parts.push(report.published ? "Published your changes." : "Already up to date.");
|
||||
}
|
||||
if (report.absorbed > 0) {
|
||||
parts.push(
|
||||
`Absorbed ${report.absorbed} conflict cop${report.absorbed === 1 ? "y" : "ies"}.`,
|
||||
);
|
||||
}
|
||||
// Conflicts are the one thing worth spelling out — the user may want to
|
||||
// correct a value that lost.
|
||||
if (report.report.conflicts.length > 0) {
|
||||
parts.push(`Kept the other copy's version of: ${report.report.conflicts.join("; ")}.`);
|
||||
}
|
||||
setSyncTest({ ok: true, message: parts.join(" ") });
|
||||
} catch (err) {
|
||||
setSyncTest({ ok: false, message: String(err) });
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,23 +658,6 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openLibrary() {
|
||||
const path = await open({
|
||||
title: "Open an existing library.db",
|
||||
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
|
||||
});
|
||||
if (typeof path === "string") await switchLibrary(path);
|
||||
}
|
||||
|
||||
async function newLibrary() {
|
||||
const path = await save({
|
||||
title: "Create a new library.db (put it in a synced folder)",
|
||||
defaultPath: "library.db",
|
||||
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
|
||||
});
|
||||
if (typeof path === "string") await switchLibrary(path);
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverPreviewProvider>
|
||||
<div className="app">
|
||||
@@ -468,7 +670,9 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="nav-items">
|
||||
{NAV.map((n) => (
|
||||
{/* Info carries library sync, which desktop needs too — it's no
|
||||
longer the mobile-only card-data page it started as. */}
|
||||
{[...NAV, INFO_TAB].map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
className={`nav-item ${tab === n.id ? "active" : ""}`}
|
||||
@@ -491,16 +695,15 @@ export default function App() {
|
||||
<span className="path" title={status?.library_path ?? ""}>
|
||||
{status?.library_path ?? ""}
|
||||
</span>
|
||||
{/* Choosing where library.db lives is desktop-only: on Android the
|
||||
live db must stay in app-private storage (SQLite can't open a
|
||||
content:// URI), so these would only ever fail there. */}
|
||||
{/* Desktop-only: Android grants a folder through the system picker
|
||||
instead of naming a path, so these live in the Info tab there. */}
|
||||
{status && !status.is_mobile && (
|
||||
<>
|
||||
<button onClick={openLibrary} disabled={busy}>
|
||||
<button onClick={pickLibrary} disabled={busy || syncBusy}>
|
||||
{ICON_OPEN_LIBRARY}
|
||||
Open Library
|
||||
</button>
|
||||
<button onClick={newLibrary} disabled={busy}>
|
||||
<button onClick={newLibrary} disabled={busy || syncBusy}>
|
||||
{ICON_NEW_LIBRARY}
|
||||
New Library
|
||||
</button>
|
||||
@@ -599,11 +802,12 @@ export default function App() {
|
||||
) : tab === "decks" ? (
|
||||
<DeckView refreshSignal={deckRefreshSignal} />
|
||||
) : (
|
||||
// Mobile-only tab (see INFO_TAB) — the desktop equivalent is
|
||||
// split between the topbar's Update card data button (hidden
|
||||
// here on mobile) and the nav rail's footer summary (which
|
||||
// has nowhere to live once the rail collapses to the bottom
|
||||
// nav), so both land here together.
|
||||
// The per-install status page: card data, library sync, and the
|
||||
// collection summary. On mobile it's the only home for the
|
||||
// card-data buttons (no topbar room) and the summary (the nav
|
||||
// rail it normally sits in is replaced by the bottom nav);
|
||||
// on desktop those also appear in the topbar and rail, but sync
|
||||
// lives only here.
|
||||
<div className="panel info-panel">
|
||||
<h2 style={{ margin: 0 }}>Card database</h2>
|
||||
<div className="hint" style={{ wordBreak: "break-all" }}>
|
||||
@@ -620,6 +824,85 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
<hr className="deck-section-divider" style={{ margin: 0 }} />
|
||||
<h2 style={{ margin: 0 }}>Your library</h2>
|
||||
{sync === null ? (
|
||||
<div className="hint">Checking…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hint">
|
||||
Your collection is kept in a single file. Put it in a folder you sync and
|
||||
every device stays in step — edits made in two places are combined, not
|
||||
overwritten.
|
||||
</div>
|
||||
{sync.label && (
|
||||
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
|
||||
{sync.label}
|
||||
</div>
|
||||
)}
|
||||
{sync.chosen && !sync.granted && (
|
||||
<div className="sync-bad">
|
||||
✕{" "}
|
||||
{sync.picker_based
|
||||
? "Access to that folder was lost — choose it again."
|
||||
: "That folder isn't there right now — is the drive mounted?"}
|
||||
</div>
|
||||
)}
|
||||
{syncTest && (
|
||||
<div className={syncTest.ok ? "sync-ok" : "sync-bad"}>
|
||||
{syncTest.ok ? "✓ " : "✕ "}
|
||||
{syncTest.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
{sync.granted && (
|
||||
<button className="primary" onClick={syncNow} disabled={syncBusy}>
|
||||
{syncBusy ? "Syncing…" : "Sync now"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={sync.granted ? "" : "primary"}
|
||||
onClick={pickLibrary}
|
||||
disabled={syncBusy}
|
||||
>
|
||||
{sync.chosen
|
||||
? sync.picker_based
|
||||
? "Change folder…"
|
||||
: "Change file…"
|
||||
: sync.picker_based
|
||||
? "Choose folder…"
|
||||
: "Choose file…"}
|
||||
</button>
|
||||
{sync.chosen && (
|
||||
<button onClick={forgetLibrary} disabled={syncBusy}>
|
||||
Forget
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Only shown while an old library.db is still waiting; the
|
||||
offer disappears for good once it's been converted. */}
|
||||
{sync.legacy_library && (
|
||||
<>
|
||||
<div className="hint">
|
||||
An older library from a previous version is still on this device. Convert
|
||||
it to bring your collection across.
|
||||
</div>
|
||||
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
|
||||
{sync.legacy_library}
|
||||
</div>
|
||||
<div className="row">
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => convertLegacy(sync.legacy_library!)}
|
||||
disabled={syncBusy}
|
||||
>
|
||||
Convert old library
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<hr className="deck-section-divider" style={{ margin: 0 }} />
|
||||
<LibrarySummary ready={ready} libStats={libStats} status={status} />
|
||||
</div>
|
||||
)}
|
||||
@@ -655,7 +938,7 @@ export default function App() {
|
||||
<CommandPalette
|
||||
onNavigate={setTab}
|
||||
onUpdateCardData={updateCardData}
|
||||
onOpenLibrary={openLibrary}
|
||||
onOpenLibrary={pickLibrary}
|
||||
onNewLibrary={newLibrary}
|
||||
undo={undo}
|
||||
onRunUndo={runUndo}
|
||||
|
||||
69
src/api.ts
69
src/api.ts
@@ -94,6 +94,28 @@ export interface LibraryFilter {
|
||||
in_deck?: number | null;
|
||||
location?: string | null;
|
||||
oracle_text?: string | null;
|
||||
/** Hide cards that exist *only* as Universes Beyond (Lord of the Rings,
|
||||
* Warhammer 40,000, Marvel, …). Ordinary cards reprinted into a UB set are
|
||||
* kept. Needs card data downloaded after this shipped; inert otherwise. */
|
||||
exclude_universes_beyond?: boolean;
|
||||
/** Hide Arena/MTGO-only cards (Alchemy rebalances) — they can't be owned on
|
||||
* paper. Needs card data downloaded after this shipped; inert otherwise. */
|
||||
exclude_digital_only?: boolean;
|
||||
/** Hide Un-set cards (Unglued, Unhinged, Unstable, Unfinity). */
|
||||
exclude_funny?: boolean;
|
||||
/** Cheapest known printing in USD; cards with no known price count as 0. */
|
||||
price_min?: number | null;
|
||||
price_max?: number | null;
|
||||
/** Format slug ("commander", "modern", …). Legal and restricted match. */
|
||||
legal_in?: string | null;
|
||||
/** Only cards that can head a Commander deck. */
|
||||
can_be_commander?: boolean;
|
||||
/** Printed power/toughness. Cards printed with "*" or "1+*" are excluded,
|
||||
* since those can't be compared numerically. */
|
||||
power_min?: number | null;
|
||||
power_max?: number | null;
|
||||
toughness_min?: number | null;
|
||||
toughness_max?: number | null;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
@@ -108,6 +130,40 @@ export interface Status {
|
||||
is_mobile: boolean;
|
||||
}
|
||||
|
||||
/** State of the user's library document — the JSON file they keep in whatever
|
||||
* folder they sync. The SQLite database behind it is internal and never shown. */
|
||||
export interface SyncStatus {
|
||||
/** Whether a document has been chosen. */
|
||||
chosen: boolean;
|
||||
/** Whether it's currently reachable. Distinct from `chosen`: an Android grant
|
||||
* can be revoked and a desktop folder can be deleted or unmounted — both need
|
||||
* re-picking rather than first-time setup. */
|
||||
granted: boolean;
|
||||
label: string | null;
|
||||
/** True where the folder is chosen through the system picker (Android) rather
|
||||
* than by path — the one thing the UI branches on. */
|
||||
picker_based: boolean;
|
||||
/** A pre-document library.db from an older build, waiting to be converted. */
|
||||
legacy_library: string | null;
|
||||
}
|
||||
|
||||
/** What one sync did. */
|
||||
export interface SyncReport {
|
||||
/** Whether the merge altered the local library. */
|
||||
changed: boolean;
|
||||
/** Whether the document was rewritten (skipped when we had nothing to add). */
|
||||
published: boolean;
|
||||
/** Conflict copies absorbed and cleaned up. */
|
||||
absorbed: number;
|
||||
report: {
|
||||
cards_changed: number;
|
||||
decks_changed: number;
|
||||
decks_removed: number;
|
||||
/** Fields both sides changed differently, described in user terms. */
|
||||
conflicts: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
unique_cards: number;
|
||||
total_copies: number;
|
||||
@@ -181,11 +237,22 @@ export function legalStatusFor(legalities: number, formatLabel: string): LegalSt
|
||||
|
||||
export const api = {
|
||||
getStatus: () => invoke<Status>("get_status"),
|
||||
setLibraryPath: (path: string) => invoke<Status>("set_library_path", { path }),
|
||||
updateCardData: () => invoke<Status>("update_card_data"),
|
||||
cancelUpdateCardData: () => invoke<void>("cancel_update_card_data"),
|
||||
/** Adopt an already-built reference.db from `path` instead of downloading it. */
|
||||
importReferenceDb: (path: string) => invoke<Status>("import_reference_db", { path }),
|
||||
syncStatus: () => invoke<SyncStatus>("sync_status"),
|
||||
/** Merge the library document with local edits and publish the result.
|
||||
* Resolves with `null` when a sync was already running and this call stood
|
||||
* down — expected, since sync also fires automatically. */
|
||||
syncNow: () => invoke<SyncReport | null>("sync_now"),
|
||||
/** Android: grant access to a folder through the system picker. */
|
||||
syncPickFolder: () => invoke<SyncStatus>("sync_pick_folder"),
|
||||
/** Desktop: adopt a library document chosen with the file dialog. */
|
||||
syncSetDocument: (path: string) => invoke<SyncStatus>("sync_set_document", { path }),
|
||||
syncForgetDocument: () => invoke<SyncStatus>("sync_forget_document"),
|
||||
/** Convert a pre-document library.db into the current format. */
|
||||
convertLegacyLibrary: (path: string) => invoke<SyncReport>("convert_legacy_library", { path }),
|
||||
|
||||
searchCards: (query: string, limit = 20) =>
|
||||
invoke<CardRef[]>("search_cards", { query, limit }),
|
||||
|
||||
@@ -8,7 +8,16 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { api, CardRef, COLORS, LEGALITY_FORMATS, legalStatusFor, maskToLetters, PreviewCard } from "./api";
|
||||
import {
|
||||
api,
|
||||
CardRef,
|
||||
COLORS,
|
||||
LEGALITY_FORMATS,
|
||||
LibraryFilter,
|
||||
legalStatusFor,
|
||||
maskToLetters,
|
||||
PreviewCard,
|
||||
} from "./api";
|
||||
|
||||
// --- Icons ------------------------------------------------------------
|
||||
|
||||
@@ -1386,3 +1395,296 @@ export function ColorToggles({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Shared card criteria -------------------------------------------------
|
||||
|
||||
/** Ringed planet — a card from another world/property (Universes Beyond). */
|
||||
const ICON_CROSSOVER = icon(
|
||||
<>
|
||||
<circle cx="12" cy="11" r="6" />
|
||||
<ellipse cx="12" cy="11" rx="11" ry="4" transform="rotate(-20 12 11)" />
|
||||
</>,
|
||||
);
|
||||
/** Monitor — exists only on Arena/MTGO. */
|
||||
const ICON_SCREEN = icon(
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
<path d="M9 20h6" />
|
||||
<path d="M12 16v4" />
|
||||
</>,
|
||||
);
|
||||
/** Smiley — the joke sets. */
|
||||
const ICON_SMILEY = icon(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
|
||||
<line x1="9" y1="9.5" x2="9.01" y2="9.5" />
|
||||
<line x1="15" y1="9.5" x2="15.01" y2="9.5" />
|
||||
</>,
|
||||
);
|
||||
/** Crown — can head a Commander deck. */
|
||||
const ICON_CROWN = icon(
|
||||
<>
|
||||
<path d="M3 8l3.5 3L12 5l5.5 6L21 8l-1.5 10h-15z" />
|
||||
<line x1="5" y1="21" x2="19" y2="21" />
|
||||
</>,
|
||||
);
|
||||
|
||||
/**
|
||||
* Card-origin and numeric criteria shared by the Library and Card Search
|
||||
* filter panels. Kept here rather than duplicated in both views, since they
|
||||
* must stay in step: a filter that behaves differently between browsing what
|
||||
* you own and searching everything would be a bug, not a feature.
|
||||
*
|
||||
* Numeric fields are strings so the inputs can be empty (meaning "no bound")
|
||||
* rather than coercing a blank to 0.
|
||||
*/
|
||||
export type CardCriteria = {
|
||||
excludeUb: boolean;
|
||||
excludeDigital: boolean;
|
||||
excludeFunny: boolean;
|
||||
priceMin: string;
|
||||
priceMax: string;
|
||||
legalIn: string;
|
||||
canBeCommander: boolean;
|
||||
powerMin: string;
|
||||
powerMax: string;
|
||||
toughMin: string;
|
||||
toughMax: string;
|
||||
};
|
||||
|
||||
export const EMPTY_CRITERIA: CardCriteria = {
|
||||
excludeUb: false,
|
||||
excludeDigital: false,
|
||||
excludeFunny: false,
|
||||
priceMin: "",
|
||||
priceMax: "",
|
||||
legalIn: "",
|
||||
canBeCommander: false,
|
||||
powerMin: "",
|
||||
powerMax: "",
|
||||
toughMin: "",
|
||||
toughMax: "",
|
||||
};
|
||||
|
||||
export function useCardCriteria(key: string): [CardCriteria, (patch: Partial<CardCriteria>) => void] {
|
||||
const [value, setValue] = usePersistedState<CardCriteria>(key, EMPTY_CRITERIA);
|
||||
// Merged over the defaults so a stored object written before a field existed
|
||||
// doesn't leave it undefined.
|
||||
const merged = { ...EMPTY_CRITERIA, ...value };
|
||||
const patch = useCallback(
|
||||
(p: Partial<CardCriteria>) => setValue({ ...EMPTY_CRITERIA, ...value, ...p }),
|
||||
[setValue, value],
|
||||
);
|
||||
return [merged, patch];
|
||||
}
|
||||
|
||||
const num = (s: string): number | null => (s.trim() === "" ? null : Number(s));
|
||||
|
||||
/** Fold criteria into the filter sent to the backend. */
|
||||
export function applyCardCriteria(c: CardCriteria): Partial<LibraryFilter> {
|
||||
return {
|
||||
exclude_universes_beyond: c.excludeUb,
|
||||
exclude_digital_only: c.excludeDigital,
|
||||
exclude_funny: c.excludeFunny,
|
||||
price_min: num(c.priceMin),
|
||||
price_max: num(c.priceMax),
|
||||
legal_in: c.legalIn || null,
|
||||
can_be_commander: c.canBeCommander,
|
||||
power_min: num(c.powerMin),
|
||||
power_max: num(c.powerMax),
|
||||
toughness_min: num(c.toughMin),
|
||||
toughness_max: num(c.toughMax),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether these criteria amount to a search in their own right.
|
||||
*
|
||||
* The three "exclude" toggles deliberately don't count: they refine a result
|
||||
* set rather than select one, so ticking "hide Un-sets" on its own shouldn't
|
||||
* make Card Search pull every card in the database. Choosing a format or a
|
||||
* power range is a real query, and does.
|
||||
*/
|
||||
export function criteriaActive(c: CardCriteria): boolean {
|
||||
return (
|
||||
c.canBeCommander ||
|
||||
c.legalIn !== "" ||
|
||||
[c.priceMin, c.priceMax, c.powerMin, c.powerMax, c.toughMin, c.toughMax].some(
|
||||
(v) => v.trim() !== "",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function RangePair({
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
onMin,
|
||||
onMax,
|
||||
step,
|
||||
prefix,
|
||||
}: {
|
||||
label: string;
|
||||
min: string;
|
||||
max: string;
|
||||
onMin: (v: string) => void;
|
||||
onMax: (v: string) => void;
|
||||
step?: string;
|
||||
prefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="field">
|
||||
<label>{label}</label>
|
||||
<div className="row">
|
||||
{prefix && <span className="muted">{prefix}</span>}
|
||||
<input
|
||||
type="number"
|
||||
step={step}
|
||||
style={{ width: 70 }}
|
||||
placeholder="min"
|
||||
value={min}
|
||||
onChange={(e) => onMin(e.target.value)}
|
||||
/>
|
||||
<span className="muted">–</span>
|
||||
{prefix && <span className="muted">{prefix}</span>}
|
||||
<input
|
||||
type="number"
|
||||
step={step}
|
||||
style={{ width: 70 }}
|
||||
placeholder="max"
|
||||
value={max}
|
||||
onChange={(e) => onMax(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardCriteriaFields({
|
||||
value: c,
|
||||
onChange,
|
||||
}: {
|
||||
value: CardCriteria;
|
||||
onChange: (patch: Partial<CardCriteria>) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="field">
|
||||
<label>Legal in</label>
|
||||
<select value={c.legalIn} onChange={(e) => onChange({ legalIn: e.target.value })}>
|
||||
<option value="">Any format</option>
|
||||
{LEGALITY_FORMATS.map((f) => (
|
||||
<option key={f.key} value={f.key}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<RangePair
|
||||
label="Price (USD)"
|
||||
prefix="$"
|
||||
step="0.01"
|
||||
min={c.priceMin}
|
||||
max={c.priceMax}
|
||||
onMin={(v) => onChange({ priceMin: v })}
|
||||
onMax={(v) => onChange({ priceMax: v })}
|
||||
/>
|
||||
<RangePair
|
||||
label="Power"
|
||||
min={c.powerMin}
|
||||
max={c.powerMax}
|
||||
onMin={(v) => onChange({ powerMin: v })}
|
||||
onMax={(v) => onChange({ powerMax: v })}
|
||||
/>
|
||||
<RangePair
|
||||
label="Toughness"
|
||||
min={c.toughMin}
|
||||
max={c.toughMax}
|
||||
onMin={(v) => onChange({ toughMin: v })}
|
||||
onMax={(v) => onChange({ toughMax: v })}
|
||||
/>
|
||||
|
||||
<div className="field">
|
||||
<label>Quick filters</label>
|
||||
<div className="icon-toggles">
|
||||
{/* Exclusions read as "no X" — tinted with the danger colour when
|
||||
active, so removing things looks different from selecting them. */}
|
||||
<IconToggle
|
||||
icon={ICON_CROSSOVER}
|
||||
label="Hide Universes Beyond"
|
||||
hint="Cards that only exist as another property — Lord of the Rings, Warhammer 40,000, Marvel. Normal cards reprinted in those sets stay."
|
||||
exclude
|
||||
on={c.excludeUb}
|
||||
onToggle={() => onChange({ excludeUb: !c.excludeUb })}
|
||||
/>
|
||||
<IconToggle
|
||||
icon={ICON_SCREEN}
|
||||
label="Hide digital-only"
|
||||
hint="Arena and MTGO cards such as Alchemy rebalances, which don't exist on paper."
|
||||
exclude
|
||||
on={c.excludeDigital}
|
||||
onToggle={() => onChange({ excludeDigital: !c.excludeDigital })}
|
||||
/>
|
||||
<IconToggle
|
||||
icon={ICON_SMILEY}
|
||||
label="Hide Un-set cards"
|
||||
hint="Unglued, Unhinged, Unstable and Unfinity joke cards."
|
||||
exclude
|
||||
on={c.excludeFunny}
|
||||
onToggle={() => onChange({ excludeFunny: !c.excludeFunny })}
|
||||
/>
|
||||
<span className="icon-toggles-sep" />
|
||||
<IconToggle
|
||||
icon={ICON_CROWN}
|
||||
label="Only cards that can be a commander"
|
||||
hint="Legendary creatures, plus cards whose rules text says they can be your commander."
|
||||
on={c.canBeCommander}
|
||||
onToggle={() => onChange({ canBeCommander: !c.canBeCommander })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single icon filter toggle, styled like [`ColorToggles`]' pips: dim when
|
||||
* off, lit with a ring when on. `exclude` marks a filter that *removes* cards
|
||||
* rather than selecting them, which is worth distinguishing visually since
|
||||
* both live in the same row.
|
||||
*/
|
||||
function IconToggle({
|
||||
icon,
|
||||
label,
|
||||
hint,
|
||||
on,
|
||||
exclude,
|
||||
onToggle,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
hint: string;
|
||||
on: boolean;
|
||||
exclude?: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`icon-toggle ${exclude ? "exclude" : ""} ${on ? "on" : ""}`}
|
||||
// Title carries the full explanation the old inline hint text did — the
|
||||
// whole point of this row is to stop that prose eating the panel.
|
||||
title={`${label}\n\n${hint}`}
|
||||
aria-label={label}
|
||||
aria-pressed={on}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{icon}
|
||||
{/* A slash over the glyph when an exclusion is active, so "hidden" is
|
||||
legible without reading the tooltip. */}
|
||||
{exclude && on && <span className="icon-toggle-slash" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -784,6 +784,28 @@ input[type="checkbox"] {
|
||||
gap: var(--space-3);
|
||||
max-width: 480px;
|
||||
}
|
||||
/* The currently chosen synced folder. */
|
||||
.sync-folder {
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Outcome of the synced-folder self-test. Not a `.hint`: whether sync works is
|
||||
the point of the section, so it reads at body size rather than as
|
||||
de-emphasized supporting text. */
|
||||
.sync-ok,
|
||||
.sync-bad {
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sync-ok {
|
||||
color: var(--success);
|
||||
}
|
||||
.sync-bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Typography helpers
|
||||
@@ -1695,6 +1717,65 @@ tbody tr:hover {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
/* Compact icon filter toggles, following .color-toggles' visual grammar: dim
|
||||
when off, lit with an accent ring when on. Replaces a stack of labelled
|
||||
checkboxes that ate most of the filter panel's height — the explanations
|
||||
moved into tooltips. */
|
||||
.icon-toggles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.icon-toggle {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elev);
|
||||
color: var(--text);
|
||||
opacity: 0.4;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.1s, box-shadow 0.1s, color 0.1s;
|
||||
}
|
||||
.icon-toggle:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.icon-toggle.on {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 2px var(--accent-hover);
|
||||
}
|
||||
/* Removing cards should not look like selecting them. */
|
||||
.icon-toggle.exclude.on {
|
||||
color: var(--danger);
|
||||
box-shadow: 0 0 0 2px var(--danger);
|
||||
}
|
||||
/* Diagonal bar across an active exclusion, so "hidden" reads without the
|
||||
tooltip. */
|
||||
.icon-toggle-slash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
transparent calc(50% - 1px),
|
||||
currentColor calc(50% - 1px),
|
||||
currentColor calc(50% + 1px),
|
||||
transparent calc(50% + 1px)
|
||||
);
|
||||
}
|
||||
/* Separates the exclusions from the selecting filter beside them. */
|
||||
.icon-toggles-sep {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
margin: 2px 3px;
|
||||
background: var(--border-soft);
|
||||
}
|
||||
|
||||
.location-input {
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
|
||||
import {
|
||||
CardThumb,
|
||||
@@ -15,8 +15,12 @@ import {
|
||||
SortMode,
|
||||
TagInput,
|
||||
ViewToggle,
|
||||
CardCriteriaFields,
|
||||
applyCardCriteria,
|
||||
criteriaActive,
|
||||
groupCards,
|
||||
sortCards,
|
||||
useCardCriteria,
|
||||
usePersistedState,
|
||||
useViewMode,
|
||||
} from "../components";
|
||||
@@ -50,6 +54,7 @@ export function CardSearch({
|
||||
const [subtypes, setSubtypes] = useState<string[]>([]);
|
||||
const [keywords, setKeywords] = useState<string[]>([]);
|
||||
const [oracleText, setOracleText] = useState("");
|
||||
const [criteria, patchCriteria] = useCardCriteria("cardsearch-criteria");
|
||||
// Collapsed by default (relevant on narrow/mobile screens only — see
|
||||
// .filters-more's CSS, which is always expanded above that breakpoint
|
||||
// regardless of this).
|
||||
@@ -62,6 +67,10 @@ export function CardSearch({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<Set<string>>(new Set());
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false);
|
||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [view, setView] = useViewMode("cardsearch-view");
|
||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("cardsearch-group", "none");
|
||||
@@ -85,7 +94,8 @@ export function CardSearch({
|
||||
types.length > 0 ||
|
||||
subtypes.length > 0 ||
|
||||
keywords.length > 0 ||
|
||||
oracleText.trim() !== "";
|
||||
oracleText.trim() !== "" ||
|
||||
criteriaActive(criteria);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveFilter) {
|
||||
@@ -103,6 +113,7 @@ export function CardSearch({
|
||||
subtypes,
|
||||
keywords,
|
||||
oracle_text: oracleText.trim() || null,
|
||||
...applyCardCriteria(criteria),
|
||||
};
|
||||
const t = setTimeout(() => {
|
||||
api
|
||||
@@ -128,9 +139,82 @@ export function CardSearch({
|
||||
subtypes,
|
||||
keywords,
|
||||
oracleText,
|
||||
criteria,
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
// Drop selections that fall out of the result set when the filter changes,
|
||||
// so a hidden card can't be silently added to a deck later.
|
||||
useEffect(() => {
|
||||
setSelected((s) => {
|
||||
const visible = new Set(rows.map((r) => r.oracle_id));
|
||||
const next = new Set([...s].filter((id) => visible.has(id)));
|
||||
return next.size === s.size ? s : next;
|
||||
});
|
||||
}, [rows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectAllRef.current) {
|
||||
selectAllRef.current.indeterminate = selected.size > 0 && selected.size < rows.length;
|
||||
}
|
||||
}, [selected, rows]);
|
||||
|
||||
function toggleOne(oracleId: string) {
|
||||
setSelected((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(oracleId)) next.delete(oracleId);
|
||||
else next.add(oracleId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
setSelected((s) => (s.size === rows.length ? new Set() : new Set(rows.map((r) => r.oracle_id))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add every selected card to the deck chosen in the header, at one copy each.
|
||||
*
|
||||
* Mirrors the Library tab's bulk add, including that it *sets* the quantity
|
||||
* rather than incrementing — so re-running it is idempotent — and offers a
|
||||
* single undo restoring every card's prior deck entry at once.
|
||||
*/
|
||||
async function bulkAddToDeck() {
|
||||
if (deckId === "") return;
|
||||
const deck = decks.find((d) => d.id === deckId);
|
||||
if (!deck) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const targets = rows.filter((r) => selected.has(r.oracle_id));
|
||||
const before = await api.deckContents(deck.id);
|
||||
const prevByOracle = new Map(before.map((e) => [e.oracle_id, e]));
|
||||
await Promise.all(
|
||||
targets.map((r) => api.setDeckCard(deck.id, r.oracle_id, 1, false, bulkToMaybeboard)),
|
||||
);
|
||||
setRefreshKey((k) => k + 1);
|
||||
onOfferUndo(
|
||||
`Added ${targets.length} card${targets.length === 1 ? "" : "s"} to ${deck.name}.`,
|
||||
async () => {
|
||||
await Promise.all(
|
||||
targets.map((r) => {
|
||||
const prev = prevByOracle.get(r.oracle_id);
|
||||
return api.setDeckCard(
|
||||
deck.id,
|
||||
r.oracle_id,
|
||||
prev?.quantity ?? 0,
|
||||
prev?.is_commander ?? false,
|
||||
prev?.is_maybeboard ?? false,
|
||||
);
|
||||
}),
|
||||
);
|
||||
setRefreshKey((k) => k + 1);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function adjust(oracleId: string, delta: number) {
|
||||
const qty = await api.addOwned(oracleId, delta);
|
||||
setRows((rs) => rs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c)));
|
||||
@@ -176,6 +260,7 @@ export function CardSearch({
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>Colors</th>
|
||||
<th>Name</th>
|
||||
@@ -189,6 +274,13 @@ export function CardSearch({
|
||||
<tbody>
|
||||
{cards.map((c) => (
|
||||
<tr key={c.oracle_id}>
|
||||
<td className="cell-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(c.oracle_id)}
|
||||
onChange={() => toggleOne(c.oracle_id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="cell-thumb">
|
||||
<CardThumb card={c} />
|
||||
</td>
|
||||
@@ -235,6 +327,14 @@ export function CardSearch({
|
||||
<CardTile
|
||||
key={c.oracle_id}
|
||||
card={c}
|
||||
selected={selected.has(c.oracle_id)}
|
||||
cornerLeft={
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(c.oracle_id)}
|
||||
onChange={() => toggleOne(c.oracle_id)}
|
||||
/>
|
||||
}
|
||||
corner={
|
||||
<QtyStepper
|
||||
overlay
|
||||
@@ -290,6 +390,61 @@ export function CardSearch({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="bulk-bar">
|
||||
<span className="bulk-bar-count">{selected.size} selected</span>
|
||||
<div className="bulk-bar-actions">
|
||||
{/* Bound to the same deck as the header picker rather than a
|
||||
separate one — two controls that disagreed about the target
|
||||
deck would be worse than the redundancy. It's repeated here
|
||||
because a disabled button whose reason lives elsewhere on the
|
||||
page reads as broken. */}
|
||||
<select
|
||||
value={deckId}
|
||||
onChange={(e) => setDeckId(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
disabled={bulkBusy}
|
||||
>
|
||||
<option value="">Choose a deck…</option>
|
||||
{decks.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={bulkAddToDeck}
|
||||
disabled={bulkBusy || deckId === ""}
|
||||
title={
|
||||
deckId === ""
|
||||
? "Choose a deck first"
|
||||
: "Set each selected card to 1 copy in the deck"
|
||||
}
|
||||
>
|
||||
Add to deck
|
||||
</button>
|
||||
<label
|
||||
className="row"
|
||||
style={{ gap: 4, cursor: "pointer" }}
|
||||
title="Send to the maybeboard instead of the mainboard"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkToMaybeboard}
|
||||
onChange={(e) => setBulkToMaybeboard(e.target.checked)}
|
||||
disabled={bulkBusy}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
Maybeboard
|
||||
</span>
|
||||
</label>
|
||||
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel workspace">
|
||||
<div className="filters">
|
||||
<div className="field">
|
||||
@@ -355,6 +510,7 @@ export function CardSearch({
|
||||
placeholder="e.g. landfall"
|
||||
/>
|
||||
</div>
|
||||
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -365,6 +521,17 @@ export function CardSearch({
|
||||
</button>
|
||||
|
||||
<div className="workspace-bar">
|
||||
{rows.length > 0 && (
|
||||
<label className="workspace-bar-check" title="Select all matching cards">
|
||||
<input
|
||||
ref={selectAllRef}
|
||||
type="checkbox"
|
||||
checked={rows.length > 0 && selected.size === rows.length}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
Select all
|
||||
</label>
|
||||
)}
|
||||
{hasActiveFilter && (
|
||||
<span className="workspace-bar-count">
|
||||
{rows.length} result{rows.length === 1 ? "" : "s"}
|
||||
|
||||
@@ -17,8 +17,11 @@ import {
|
||||
SortMode,
|
||||
TagInput,
|
||||
ViewToggle,
|
||||
CardCriteriaFields,
|
||||
applyCardCriteria,
|
||||
groupCards,
|
||||
sortCards,
|
||||
useCardCriteria,
|
||||
usePersistedState,
|
||||
useViewMode,
|
||||
} from "../components";
|
||||
@@ -54,6 +57,7 @@ export function LibraryBrowser({
|
||||
const [inDeck, setInDeck] = useState<number | "">("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [oracleText, setOracleText] = useState("");
|
||||
const [criteria, patchCriteria] = useCardCriteria("library-criteria");
|
||||
// Collapsed by default (relevant on narrow/mobile screens only — see
|
||||
// .filters-more's CSS, which is always expanded above that breakpoint
|
||||
// regardless of this).
|
||||
@@ -123,6 +127,7 @@ export function LibraryBrowser({
|
||||
in_deck: inDeck === "" ? null : Number(inDeck),
|
||||
location: location.trim() || null,
|
||||
oracle_text: oracleText.trim() || null,
|
||||
...applyCardCriteria(criteria),
|
||||
};
|
||||
const t = setTimeout(() => {
|
||||
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
|
||||
@@ -140,6 +145,7 @@ export function LibraryBrowser({
|
||||
inDeck,
|
||||
location,
|
||||
oracleText,
|
||||
criteria,
|
||||
refreshKey,
|
||||
]);
|
||||
|
||||
@@ -607,6 +613,7 @@ export function LibraryBrowser({
|
||||
placeholder="e.g. landfall"
|
||||
/>
|
||||
</div>
|
||||
<CardCriteriaFields value={criteria} onChange={patchCriteria} />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user