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.
2026-08-07 22:03:49 -05:00
|
|
|
|
//! The library as a portable JSON document, and the merge that reconciles two
|
|
|
|
|
|
//! copies of it.
|
|
|
|
|
|
//!
|
Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
|
|
|
|
//! The document is the library's shape for exchange: what a sync server stores
|
|
|
|
|
|
//! (see [`remote`]), what a backup export writes, and what an import reads. On
|
|
|
|
|
|
//! each device the library lives in an internal SQLite working copy, which can
|
|
|
|
|
|
//! always be rebuilt by merging a document into an empty one — so "first
|
|
|
|
|
|
//! sync", "imported a backup", and "changed on another device" are all the same
|
|
|
|
|
|
//! code path.
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
//!
|
|
|
|
|
|
//! # 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
|
|
|
|
|
|
//!
|
Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
|
|
|
|
//! The stored ancestor must be **the remote document as last known**, never
|
|
|
|
|
|
//! the result we last *tried* to publish. If a publish is lost — the
|
|
|
|
|
|
//! connection dropped, the process died — the remote is still what we last
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
//! read, so the next merge sees our edits as ours and republishes them.
|
Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
|
|
|
|
//! Recording the attempted 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. (A publish the server *confirmed* is
|
|
|
|
|
|
//! known, so the ancestor may advance to it — see [`remote::sync_cycle`].)
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
//!
|
|
|
|
|
|
//! # 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;
|
|
|
|
|
|
|
Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
|
|
|
|
pub mod account;
|
|
|
|
|
|
pub mod remote;
|
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
/// The newest document format this build reads. Readers reject anything newer
|
|
|
|
|
|
/// rather than silently misreading it.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// A document is written at the *lowest* version that can hold it (see
|
|
|
|
|
|
/// [`LibraryDoc::required_version`]), not at this one. A reader that silently
|
|
|
|
|
|
/// dropped printing data would publish its loss as a deletion on the next
|
|
|
|
|
|
/// sync, so documents that carry any are marked 2 and older builds refuse
|
|
|
|
|
|
/// them — while libraries that don't use printings stay readable everywhere.
|
|
|
|
|
|
pub const DOC_VERSION: u32 = 2;
|
|
|
|
|
|
|
|
|
|
|
|
/// The format of a document with no printing data in it.
|
|
|
|
|
|
const BASE_VERSION: u32 = 1;
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
|
|
|
|
|
|
/// 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,
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
/// Copies pinned to a printing + finish, keyed by [`printing_key`]. A
|
|
|
|
|
|
/// subset of `quantity`; whatever they don't cover is unspecified.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
|
|
|
|
pub printings: BTreeMap<String, i64>,
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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,
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
/// Scryfall id of the printing whose art this slot shows.
|
|
|
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
|
pub printing: Option<String>,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The [`CardEntry::printings`] key for a copy group: `"<scryfall_id>/<finish>"`.
|
|
|
|
|
|
/// Scryfall ids are UUIDs, so the separator can't be ambiguous.
|
|
|
|
|
|
pub fn printing_key(scryfall_id: &str, finish: &str) -> String {
|
|
|
|
|
|
format!("{scryfall_id}/{finish}")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Split a [`printing_key`] back into `(scryfall_id, finish)`.
|
|
|
|
|
|
pub fn parse_printing_key(key: &str) -> Option<(&str, &str)> {
|
|
|
|
|
|
key.rsplit_once('/')
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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.
|
|
|
|
|
|
///
|
|
|
|
|
|
#[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 {
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
version: BASE_VERSION,
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
|
|
|
|
|
|
/// The lowest format that can hold this document: 2 once it carries any
|
|
|
|
|
|
/// printing data, else 1. See [`DOC_VERSION`].
|
|
|
|
|
|
pub fn required_version(&self) -> u32 {
|
|
|
|
|
|
let has_copies = self.cards.values().any(|c| !c.printings.is_empty());
|
|
|
|
|
|
let has_deck_printings = self
|
|
|
|
|
|
.decks
|
|
|
|
|
|
.values()
|
|
|
|
|
|
.flat_map(|d| d.cards.values())
|
|
|
|
|
|
.any(|c| c.printing.is_some());
|
|
|
|
|
|
if has_copies || has_deck_printings {
|
|
|
|
|
|
2
|
|
|
|
|
|
} else {
|
|
|
|
|
|
BASE_VERSION
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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));
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
if let Some(entry) = merge_card(b, o, t, &id, &mut report) {
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
out.version = out.required_version();
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
(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())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
/// Three-way merge of one owned card. Whether it exists at all is decided
|
|
|
|
|
|
/// exactly as for any entry ([`merge_entry`]); when both sides kept it, its
|
|
|
|
|
|
/// fields merge independently and its copy groups per printing, so a quantity
|
|
|
|
|
|
/// change on one device and a printing recorded on another both survive.
|
|
|
|
|
|
fn merge_card(
|
|
|
|
|
|
base: Option<&CardEntry>,
|
|
|
|
|
|
ours: Option<&CardEntry>,
|
|
|
|
|
|
theirs: Option<&CardEntry>,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
report: &mut MergeReport,
|
|
|
|
|
|
) -> Option<CardEntry> {
|
|
|
|
|
|
let (Some(o), Some(t)) = (ours, theirs) else {
|
|
|
|
|
|
return merge_entry(base, ours, theirs, &mut || {
|
|
|
|
|
|
report.conflicts.push(format!("owned quantity for card {id}"))
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
if o == t {
|
|
|
|
|
|
return Some(o.clone());
|
|
|
|
|
|
}
|
|
|
|
|
|
let empty = CardEntry::default();
|
|
|
|
|
|
let b = base.unwrap_or(&empty);
|
|
|
|
|
|
let mut merged = CardEntry {
|
|
|
|
|
|
quantity: merge_field(&b.quantity, &o.quantity, &t.quantity, || {
|
|
|
|
|
|
report.conflicts.push(format!("owned quantity for card {id}"))
|
|
|
|
|
|
}),
|
|
|
|
|
|
location: merge_field(&b.location, &o.location, &t.location, || {
|
|
|
|
|
|
report.conflicts.push(format!("location of card {id}"))
|
|
|
|
|
|
}),
|
|
|
|
|
|
printings: BTreeMap::new(),
|
|
|
|
|
|
};
|
|
|
|
|
|
for key in keys(&b.printings, &o.printings, &t.printings) {
|
|
|
|
|
|
let qty = merge_entry(
|
|
|
|
|
|
b.printings.get(&key),
|
|
|
|
|
|
o.printings.get(&key),
|
|
|
|
|
|
t.printings.get(&key),
|
|
|
|
|
|
&mut || report.conflicts.push(format!("tracked printing of card {id}")),
|
|
|
|
|
|
);
|
|
|
|
|
|
if let Some(qty) = qty {
|
|
|
|
|
|
merged.printings.insert(key, qty);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
clamp_printings(&mut merged);
|
|
|
|
|
|
Some(merged)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Trim copy groups until they fit within the owned quantity — the merge's
|
|
|
|
|
|
/// counterpart to the database's own clamp. Trims from the end of key order
|
|
|
|
|
|
/// rather than "newest first", because a document has no edit order and the
|
|
|
|
|
|
/// result must come out the same on every device.
|
|
|
|
|
|
fn clamp_printings(card: &mut CardEntry) {
|
|
|
|
|
|
let mut excess = card.printings.values().sum::<i64>() - card.quantity.max(0);
|
|
|
|
|
|
while excess > 0 {
|
|
|
|
|
|
let Some(mut last) = card.printings.last_entry() else {
|
|
|
|
|
|
break;
|
|
|
|
|
|
};
|
|
|
|
|
|
let cut = (*last.get()).min(excess);
|
|
|
|
|
|
if cut == *last.get() {
|
|
|
|
|
|
last.remove();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
*last.get_mut() -= cut;
|
|
|
|
|
|
}
|
|
|
|
|
|
excess -= cut;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
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)?,
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
printings: BTreeMap::new(),
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
let mut stmt = conn.prepare(
|
|
|
|
|
|
"SELECT lc.oracle_id, c.scryfall_id, c.finish, c.quantity
|
|
|
|
|
|
FROM library_card_copies c JOIN library_cards lc ON lc.id = c.library_card_id
|
|
|
|
|
|
WHERE c.quantity > 0 AND c.scryfall_id IS NOT NULL AND c.finish IS NOT NULL",
|
|
|
|
|
|
)?;
|
|
|
|
|
|
let mut rows = stmt.query([])?;
|
|
|
|
|
|
while let Some(row) = rows.next()? {
|
|
|
|
|
|
let oracle_id: String = row.get(0)?;
|
|
|
|
|
|
let key = printing_key(&row.get::<_, String>(1)?, &row.get::<_, String>(2)?);
|
|
|
|
|
|
// Copies are clamped to quantity_owned, so the card is always listed.
|
|
|
|
|
|
if let Some(card) = doc.cards.get_mut(&oracle_id) {
|
|
|
|
|
|
card.printings.insert(key, row.get(3)?);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
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(),
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
let mut stmt = conn.prepare(
|
|
|
|
|
|
"SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id
|
|
|
|
|
|
FROM deck_cards",
|
|
|
|
|
|
)?;
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
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,
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
printing: row.get(5)?,
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
doc.version = doc.required_version();
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
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],
|
|
|
|
|
|
)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
// Copy groups are rebuilt wholesale: their row ids are local, and the
|
|
|
|
|
|
// document is the whole truth about them.
|
|
|
|
|
|
tx.execute("DELETE FROM library_card_copies", [])?;
|
|
|
|
|
|
for (oracle_id, entry) in &doc.cards {
|
|
|
|
|
|
for (key, qty) in &entry.printings {
|
|
|
|
|
|
let Some((scryfall_id, finish)) = parse_printing_key(key) else {
|
|
|
|
|
|
continue; // Hand-edited nonsense; nothing sensible to store.
|
|
|
|
|
|
};
|
|
|
|
|
|
tx.execute(
|
|
|
|
|
|
"INSERT INTO library_card_copies(library_card_id, scryfall_id, finish, quantity)
|
|
|
|
|
|
SELECT id, ?2, ?3, ?4 FROM library_cards WHERE oracle_id = ?1",
|
|
|
|
|
|
rusqlite::params![oracle_id, scryfall_id, finish, qty],
|
|
|
|
|
|
)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
// 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(
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id)
|
|
|
|
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
|
|
|
|
|
|
quantity = excluded.quantity,
|
|
|
|
|
|
is_commander = excluded.is_commander,
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
is_maybeboard = excluded.is_maybeboard,
|
|
|
|
|
|
preferred_scryfall_id = excluded.preferred_scryfall_id",
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
rusqlite::params![
|
|
|
|
|
|
deck_id,
|
|
|
|
|
|
oracle_id,
|
|
|
|
|
|
card.quantity,
|
|
|
|
|
|
card.commander as i64,
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
card.maybeboard as i64,
|
|
|
|
|
|
card.printing
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
],
|
|
|
|
|
|
)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
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(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Replace folder sync with an HTTP sync server, keeping the app offline-first
Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.
The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.
Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.
The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
2026-10-08 22:32:08 -05:00
|
|
|
|
/// Merge `theirs` into the working copy against `base`, and return the result.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The working copy is only rewritten when the merge changed it, so the common
|
|
|
|
|
|
/// nothing-happened sync costs one read.
|
|
|
|
|
|
pub fn merge_into(
|
|
|
|
|
|
conn: &Connection,
|
|
|
|
|
|
base: &LibraryDoc,
|
|
|
|
|
|
theirs: &LibraryDoc,
|
|
|
|
|
|
) -> Result<(LibraryDoc, MergeReport)> {
|
|
|
|
|
|
let ours = read_doc(conn)?;
|
|
|
|
|
|
let (merged, report) = merge3(base, &ours, theirs);
|
|
|
|
|
|
if merged != ours {
|
|
|
|
|
|
apply_doc(conn, &merged)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok((merged, report))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
/// 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(),
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
CardEntry { quantity: *qty, ..Default::default() },
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn a_quantity_edit_and_a_printing_added_elsewhere_both_survive() {
|
|
|
|
|
|
// Cards used to merge as one unit, so these two edits would have
|
|
|
|
|
|
// collided and one would have been lost.
|
|
|
|
|
|
let base = doc(&[("a", 2)], &[]);
|
|
|
|
|
|
let ours = doc(&[("a", 4)], &[]);
|
|
|
|
|
|
let mut theirs = base.clone();
|
|
|
|
|
|
theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1);
|
|
|
|
|
|
|
|
|
|
|
|
let (merged, report) = merge3(&base, &ours, &theirs);
|
|
|
|
|
|
|
|
|
|
|
|
let a = &merged.cards["a"];
|
|
|
|
|
|
assert_eq!(a.quantity, 4);
|
|
|
|
|
|
assert_eq!(a.printings.get(&printing_key("p1", "foil")), Some(&1));
|
|
|
|
|
|
assert!(report.conflicts.is_empty(), "different fields, no conflict");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn printings_recorded_on_each_side_merge_side_by_side() {
|
|
|
|
|
|
let base = doc(&[("a", 3)], &[]);
|
|
|
|
|
|
let mut ours = base.clone();
|
|
|
|
|
|
let mut theirs = base.clone();
|
|
|
|
|
|
ours.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "nonfoil"), 1);
|
|
|
|
|
|
theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p2", "foil"), 2);
|
|
|
|
|
|
|
|
|
|
|
|
let (merged, report) = merge3(&base, &ours, &theirs);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(merged.cards["a"].printings.len(), 2);
|
|
|
|
|
|
assert!(report.conflicts.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn merged_printings_are_clamped_the_same_way_on_every_device() {
|
|
|
|
|
|
// Each side's own edit fits, but together they track 4 of 2 copies.
|
|
|
|
|
|
let base = doc(&[("a", 3)], &[]);
|
|
|
|
|
|
let mut ours = doc(&[("a", 2)], &[]);
|
|
|
|
|
|
ours.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1);
|
|
|
|
|
|
let mut theirs = base.clone();
|
|
|
|
|
|
theirs.cards.get_mut("a").unwrap().printings.insert(printing_key("p2", "foil"), 3);
|
|
|
|
|
|
|
|
|
|
|
|
let (phone, _) = merge3(&base, &ours, &theirs);
|
|
|
|
|
|
let (desktop, _) = merge3(&base, &theirs, &ours);
|
|
|
|
|
|
|
|
|
|
|
|
assert_eq!(phone.cards["a"], desktop.cards["a"], "both devices must agree");
|
|
|
|
|
|
let a = &phone.cards["a"];
|
|
|
|
|
|
assert_eq!(a.quantity, 2);
|
|
|
|
|
|
assert_eq!(a.printings.values().sum::<i64>(), 2);
|
|
|
|
|
|
// Trimmed from the end of key order: p1 kept whole.
|
|
|
|
|
|
assert_eq!(a.printings.get(&printing_key("p1", "foil")), Some(&1));
|
|
|
|
|
|
assert_eq!(a.printings.get(&printing_key("p2", "foil")), Some(&1));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn version_2_is_used_only_once_printing_data_exists() {
|
|
|
|
|
|
let mut d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]);
|
|
|
|
|
|
assert_eq!(d.required_version(), 1);
|
|
|
|
|
|
|
|
|
|
|
|
d.decks.get_mut("u1").unwrap().cards.get_mut("a").unwrap().printing = Some("p1".into());
|
|
|
|
|
|
assert_eq!(d.required_version(), 2);
|
|
|
|
|
|
|
|
|
|
|
|
let mut d = doc(&[("a", 1)], &[]);
|
|
|
|
|
|
d.cards.get_mut("a").unwrap().printings.insert(printing_key("p1", "foil"), 1);
|
|
|
|
|
|
assert_eq!(d.required_version(), 2);
|
|
|
|
|
|
|
|
|
|
|
|
// A merge result is stamped with what it needs.
|
|
|
|
|
|
let (merged, _) = merge3(&LibraryDoc::default(), &d, &LibraryDoc::default());
|
|
|
|
|
|
assert_eq!(merged.version, 2);
|
|
|
|
|
|
// And this build reads it back, printing data intact.
|
|
|
|
|
|
let back = LibraryDoc::from_json(&merged.to_json().unwrap()).unwrap();
|
|
|
|
|
|
assert_eq!(back, merged);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn a_version_1_document_carries_no_printing_fields() {
|
|
|
|
|
|
// Older builds must see exactly what they always saw.
|
|
|
|
|
|
let d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]);
|
|
|
|
|
|
let text = d.to_json().unwrap();
|
|
|
|
|
|
assert!(text.contains("\"version\": 1"));
|
|
|
|
|
|
assert!(!text.contains("printing"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
// --- 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Printing tracking for owned copies and deck art, and an outdated-card-data notice
Owned copies can now be pinned to a printing + finish (nonfoil / foil /
etched), activating the long-dormant library_card_copies table as groups
of identical copies. library_cards.quantity_owned stays authoritative:
groups are a subset of it, and the rest are "unspecified". Tracking more
copies than are unspecified grows ownership to fit; lowering ownership
trims the most recently added groups first. Migration 0006 adds the
group quantity, a unique (card, printing, finish) index, and
deck_cards.preferred_scryfall_id.
A deck slot can show a chosen printing's art, both faces from that
printing, falling back to the default when the id no longer resolves.
It survives quantity/board changes and is kept by duplicate_deck.
UI: the click preview's info tile gets a Printings section when opened
from the Library or a deck (copy groups with steppers, "Add a
printing…", and "Shown in this deck" with Change…/Default), backed by a
printing picker grid. The picker takes Escape in the capture phase so
it closes without closing the preview under it.
Sync: CardEntry.printings (keyed "<scryfall_id>/<finish>") and
DeckCardEntry.printing. Cards now merge field by field with groups per
key, so a quantity edit and a printing added on another device both
survive; merged groups are clamped deterministically. A document is
written as version 2 only once it holds printing data, so older builds
and servers refuse it instead of dropping the data and syncing the loss.
Decklists: "(SET) NUM", optionally "*F*"/"*E*", round-trips through deck
and library export/import. Library export writes a line per group plus
one for unspecified copies.
reference.db printings gain lang and released_at for picker labels and
ordering. When the installed card data lacks any column this build's
reference schema has (found by diffing against the schema applied
in-memory, so no list to maintain), a notice under the top bar offers
Update card data (and Import card data… on phones); "Not now" is
remembered until something more goes missing.
2026-10-16 21:06:17 -05:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn printings_round_trip_through_a_document() {
|
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let c = catalog(tmp.path(), "lib");
|
|
|
|
|
|
c.conn
|
|
|
|
|
|
.execute_batch(
|
|
|
|
|
|
"INSERT INTO ref.printings(scryfall_id, oracle_id, finishes) VALUES
|
|
|
|
|
|
('p1', 'card-a', 'nonfoil,foil'), ('p2', 'card-a', 'nonfoil');",
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
c.set_owned("card-a", 5).unwrap();
|
|
|
|
|
|
c.set_library_copy("card-a", "p1", "foil", 2).unwrap();
|
|
|
|
|
|
c.set_library_copy("card-a", "p2", "nonfoil", 1).unwrap();
|
|
|
|
|
|
let deck = c.create_deck("Fub", None).unwrap();
|
|
|
|
|
|
c.set_deck_card(deck.id, "card-a", 1, false, false).unwrap();
|
|
|
|
|
|
c.set_deck_card_printing(deck.id, "card-a", Some("p2")).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let before = read_doc(&c.conn).unwrap();
|
|
|
|
|
|
assert_eq!(before.version, 2);
|
|
|
|
|
|
assert_eq!(before.cards["card-a"].printings.len(), 2);
|
|
|
|
|
|
let parsed = LibraryDoc::from_json(&before.to_json().unwrap()).unwrap();
|
|
|
|
|
|
|
|
|
|
|
|
let other = catalog(tmp.path(), "other");
|
|
|
|
|
|
apply_doc(&other.conn, &parsed).unwrap();
|
|
|
|
|
|
assert_eq!(read_doc(&other.conn).unwrap(), before);
|
|
|
|
|
|
assert_eq!(other.list_library_copies("card-a").unwrap().len(), 2);
|
|
|
|
|
|
|
|
|
|
|
|
// Applying a document without them clears them.
|
|
|
|
|
|
let mut without = before.clone();
|
|
|
|
|
|
without.cards.get_mut("card-a").unwrap().printings.clear();
|
|
|
|
|
|
apply_doc(&other.conn, &without).unwrap();
|
|
|
|
|
|
assert!(other.list_library_copies("card-a").unwrap().is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-08-07 22:03:49 -05:00
|
|
|
|
#[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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|