Initial commit: MTG personal library catalog (Tauri v2 + React/TS)

Full working app with:
- Rust core (mtgc-core): Scryfall ingester, nucleo fuzzy search, SQLite two-DB
  architecture (reference.db + library.db via ATTACH), criteria filters, deck ops
- Tauri v2 shell exposing the core as typed IPC commands
- React/TS frontend: card entry, library browser, deck editor
- Features: fuzzy card search, color/CMC/type/commander-identity filters,
  tile/list view toggle, hover card previews, deck format + commander support,
  per-card location field, grouping by type/subtype/CMC/color, localStorage
  persistence for active tab/deck/grouping, Discord-inspired dark UI (#C8553D accent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 19:37:56 -05:00
commit 399c628461
94 changed files with 11626 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Rust / Cargo
/target
**/*.rs.bk
# Node / frontend
node_modules/
/dist/
# Tauri generated
/src-tauri/gen/
# Local databases (reference.db is rebuildable; library.db is your synced data)
*.db
*.sqlite
*.db-shm
*.db-wal
# OS / editor
.DS_Store
Thumbs.db
.idea/
*.swp
*.swo

4876
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
Cargo.toml Normal file
View File

@@ -0,0 +1,25 @@
[workspace]
resolver = "2"
members = ["crates/mtgc-core", "src-tauri"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
[workspace.dependencies]
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
nucleo = "0.5"
ureq = { version = "2", features = ["json"] }
anyhow = "1"
thiserror = "1"
# Lean, self-contained release binary (used by `cargo tauri build`).
# Trades a few minutes of extra build time for a smaller executable.
[profile.release]
strip = true # drop debug symbols
lto = true # link-time optimization
codegen-units = 1 # better optimization at the cost of parallelism
panic = "abort" # no unwinding machinery

114
README.md Normal file
View File

@@ -0,0 +1,114 @@
# mtgc — Personal Magic: The Gathering Library Catalog
A desktop app (Tauri v2 + React) for cataloging your MTG collection: rapid
fuzzy card entry, rich criteria search (colors, commander color-identity
compatibility, mana value, type/subtype/keyword), and deck organization with
per-card deck membership. Built so the eventual mobile port is cheap — all logic
lives in a platform-agnostic Rust core.
## Architecture
```
src/ React + TypeScript UI (swappable presentation)
src-tauri/ Tauri v2 shell — exposes the core as typed commands
crates/mtgc-core/ Pure Rust: db, fuzzy search, filters, decks, ingester
```
Two SQLite databases, joined at runtime via `ATTACH`:
- **`reference.db`** — read-only card data built from Scryfall bulk. Bulky,
re-downloadable per device, **not synced**.
- **`library.db`** — your owned cards + decks. Small, portable, **the file you
sync** (drop it in Syncthing/Dropbox/Nextcloud). Point the app at it via
*Choose library file…*.
Both default to the OS app-data dir (e.g. `~/.local/share/com.garrettmills.mtgc/`).
## First-time setup
```bash
./scripts/devel-setup.sh # Tauri system libs (webkit2gtk/gtk3) + Tauri CLI
npm install # frontend deps
```
## Run
```bash
cargo tauri dev # launches the desktop app (dev mode)
```
On first launch, click **Download card data** — it fetches the Scryfall
*Oracle Cards* + *Default Cards* bulk (~a few hundred MB) and builds
`reference.db`. After that the app is fully offline.
You can also build the card database from the command line:
```bash
cargo run -p mtgc-core --bin mtgc-ingest -- /path/to/reference.db
```
## Production build
Build an optimized app with the frontend embedded into the executable:
```bash
./scripts/devel-setup.sh # once, if not already done
cargo tauri build
```
Outputs (under the workspace `target/release/`):
- **`target/release/mtgc-app`** — the standalone executable. The web UI and all
app logic are baked into this one file. It dynamically links the system WebKit/GTK
libraries (`webkit2gtk4.1`, `gtk3`), so those must be present on the machine that
runs it — they are on any modern desktop Linux and are installed by
`devel-setup.sh`.
- **`target/release/bundle/appimage/*.AppImage`** — a single portable file that
bundles the app together with its runtime. This is the closest thing to a
download-and-run **single self-contained binary**: `chmod +x` it and run.
**Recommended** for copying to another machine.
- **`target/release/bundle/rpm/*.rpm`** and **`.../deb/*.deb`** — native installers
(Fedora / Debian-based) that add a menu entry and icon.
Useful variants:
```bash
cargo tauri build --bundles appimage # only the portable AppImage, skip installers
cargo tauri build --no-bundle # only the bare target/release/mtgc-app binary
```
Notes:
- On Linux the truly portable single file is the **AppImage**; the bare `mtgc-app`
binary is also a single file but relies on the system WebKit/GTK libs.
- The binary does **not** embed card data — first run still downloads the Scryfall
bulk into the OS app-data dir, and your synced `library.db` lives there by default
(override with *Choose library file…*).
- The `[profile.release]` settings in `Cargo.toml` (strip + LTO) make the release
build take a few minutes but keep the binary small.
## Using it
- **Add Cards** — type a name (typos OK); ↑/↓ then Enter adds a copy.
- **Library** — filter owned cards by name, colors, commander identity (color
identity ⊆ chosen), mana value range, type/subtype/keyword, or deck. Hover a
row's thumbnail for a full-size preview that tracks the cursor. Rows show which
decks a card is in and a warning (⚠) when it's assigned to more decks than you
own. Toggle **List / Tiles** (top-right) to switch between the table and a grid
of full card images; the choice is remembered.
- Deck contents offer the same List / Tiles toggle and hover previews.
- **Decks** — create decks and assign owned cards; set per-deck quantities.
## Develop / test
```bash
cargo test -p mtgc-core # core unit tests (db, search, filters, decks, ingest)
npm run build # typecheck + build frontend
cargo build # build everything incl. the desktop app
```
## Mobile (future)
The core and frontend are platform-agnostic; `cargo tauri icon` already emitted
iOS/Android icon sets. Porting is adding `tauri ios` / `tauri android` targets —
only reference-data provisioning differs (download on-device).

View File

@@ -0,0 +1,27 @@
[package]
name = "mtgc-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Platform-agnostic core for the MTG library catalog: DB, fuzzy search, filters, decks, and Scryfall ingestion."
[lib]
name = "mtgc_core"
path = "src/lib.rs"
# Binary that builds reference.db from Scryfall bulk data.
[[bin]]
name = "mtgc-ingest"
path = "src/bin/ingest.rs"
[dependencies]
rusqlite = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
nucleo = { workspace = true }
ureq = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,28 @@
//! CLI: build reference.db from Scryfall bulk data.
//!
//! Usage: `mtgc-ingest [path/to/reference.db]` (defaults to ./reference.db)
use std::path::PathBuf;
use std::process::ExitCode;
fn main() -> ExitCode {
let dest = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("reference.db"));
eprintln!("Building reference db at {}", dest.display());
match mtgc_core::ingest::build_reference_db(&dest, |msg| eprintln!(" {msg}")) {
Ok(stats) => {
eprintln!(
"Done: {} oracle cards, {} printings.",
stats.oracle_cards, stats.printings
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("Ingest failed: {e}");
ExitCode::FAILURE
}
}
}

118
crates/mtgc-core/src/db.rs Normal file
View File

@@ -0,0 +1,118 @@
//! Database wiring: open the library, run migrations, ATTACH the reference db.
use std::path::Path;
use rusqlite::Connection;
use crate::error::Result;
/// The reference schema, applied to a freshly-created reference.db by the ingester
/// (and by tests that build a fixture reference db).
pub const REFERENCE_SCHEMA: &str = include_str!("sql/reference_schema.sql");
/// Ordered library migrations. Index + 1 is the schema version recorded in
/// `PRAGMA user_version`. Append new migrations; never edit an applied one.
const LIBRARY_MIGRATIONS: &[&str] = &[
include_str!("sql/library/0001_init.sql"),
include_str!("sql/library/0002_location.sql"),
include_str!("sql/library/0003_deck_format.sql"),
];
/// The attached alias under which the reference db is available at runtime.
pub const REF_DB: &str = "ref";
/// Correlated subquery selecting one image URL for the oracle card aliased `oc`.
/// Returns NULL when no printing has an image. Read it as `Option<String>`.
pub fn image_subquery() -> String {
format!(
"(SELECT p.image_uri FROM {REF_DB}.printings p
WHERE p.oracle_id = oc.oracle_id AND p.image_uri <> '' LIMIT 1)"
)
}
/// Create (if needed) and open a reference.db, applying the reference schema.
/// Used by the ingester and by test fixtures.
pub fn open_reference_for_build(path: &Path) -> Result<Connection> {
let conn = Connection::open(path)?;
conn.execute_batch(REFERENCE_SCHEMA)?;
Ok(conn)
}
/// Open the library db at `library_path`, run migrations, and ATTACH the
/// reference db at `reference_path` under the [`REF_DB`] alias.
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Connection> {
let conn = Connection::open(library_path)?;
conn.pragma_update(None, "foreign_keys", true)?;
migrate_library(&conn)?;
attach_reference(&conn, reference_path)?;
Ok(conn)
}
/// ATTACH a reference db to an already-open library connection.
pub fn attach_reference(conn: &Connection, reference_path: &Path) -> Result<()> {
conn.execute(
&format!("ATTACH DATABASE ?1 AS {REF_DB}"),
[reference_path.to_string_lossy()],
)?;
Ok(())
}
/// Apply any library migrations newer than the recorded `user_version`.
pub fn migrate_library(conn: &Connection) -> Result<()> {
let current: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
for (idx, sql) in LIBRARY_MIGRATIONS.iter().enumerate() {
let version = (idx + 1) as i64;
if version > current {
conn.execute_batch(&format!("BEGIN;\n{sql}\nPRAGMA user_version = {version};\nCOMMIT;"))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn migrations_are_idempotent_and_bump_version() {
let conn = Connection::open_in_memory().unwrap();
migrate_library(&conn).unwrap();
let v: i64 = conn
.pragma_query_value(None, "user_version", |r| r.get(0))
.unwrap();
assert_eq!(v, LIBRARY_MIGRATIONS.len() as i64);
// Running again is a no-op (no "table already exists" error).
migrate_library(&conn).unwrap();
// Core tables exist.
let n: i64 = conn
.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('decks','library_cards','deck_cards','library_card_copies')",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(n, 4);
}
#[test]
fn reference_schema_applies_with_fts() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(REFERENCE_SCHEMA).unwrap();
// FTS5 + trigram tokenizer must be available in the bundled SQLite.
conn.execute(
"INSERT INTO oracle_names_fts(name, oracle_id) VALUES ('Llanowar Elves','x')",
[],
)
.unwrap();
let hit: i64 = conn
.query_row(
"SELECT count(*) FROM oracle_names_fts WHERE oracle_names_fts MATCH 'lan'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(hit, 1);
}
}

View File

@@ -0,0 +1,287 @@
//! Deck operations and deck membership lookups.
use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet, Deck, DeckEntry};
use crate::Catalog;
impl Catalog {
/// Create a deck. `commander_oracle_id` is optional.
pub fn create_deck(&self, name: &str, commander_oracle_id: Option<&str>) -> Result<Deck> {
self.conn.execute(
"INSERT INTO decks(name, commander_oracle_id) VALUES (?1, ?2)",
params![name, commander_oracle_id],
)?;
let id = self.conn.last_insert_rowid();
self.get_deck(id)
.map(|d| d.expect("just-inserted deck must exist"))
}
pub fn get_deck(&self, id: i64) -> Result<Option<Deck>> {
Ok(self
.conn
.query_row(
"SELECT id, name, format, commander_oracle_id, notes FROM decks WHERE id = ?1",
params![id],
Self::map_deck,
)
.optional()?)
}
pub fn list_decks(&self) -> Result<Vec<Deck>> {
let mut stmt = self.conn.prepare(
"SELECT id, name, format, commander_oracle_id, notes
FROM decks ORDER BY name COLLATE NOCASE",
)?;
let rows = stmt
.query_map([], Self::map_deck)?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
pub fn delete_deck(&self, id: i64) -> Result<()> {
// ON DELETE CASCADE clears deck_cards.
self.conn
.execute("DELETE FROM decks WHERE id = ?1", params![id])?;
Ok(())
}
/// Set how many copies of a card belong to a deck. `quantity <= 0` removes
/// the card from the deck. Upserts on (deck_id, oracle_id).
pub fn set_deck_card(
&self,
deck_id: i64,
oracle_id: &str,
quantity: i64,
is_commander: bool,
) -> Result<()> {
if quantity <= 0 {
self.conn.execute(
"DELETE FROM deck_cards WHERE deck_id = ?1 AND oracle_id = ?2",
params![deck_id, oracle_id],
)?;
return Ok(());
}
self.conn.execute(
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
quantity = excluded.quantity,
is_commander = excluded.is_commander",
params![deck_id, oracle_id, quantity, is_commander as i64],
)?;
Ok(())
}
/// All cards in a deck — owned or not — with their in-deck quantity and how
/// many copies are owned (0 if the card isn't in the library). Callers split
/// this into "in your library" vs "not owned" groups.
pub fn deck_contents(&self, deck_id: i64) -> Result<Vec<DeckEntry>> {
let mut stmt = self.conn.prepare(&format!(
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
oc.type_line, {image}
FROM deck_cards dc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
WHERE dc.deck_id = ?1
ORDER BY oc.name COLLATE NOCASE",
image = crate::db::image_subquery()
))?;
let rows = stmt
.query_map(params![deck_id], |r| {
Ok(DeckEntry {
quantity: r.get(0)?,
is_commander: r.get::<_, i64>(1)? != 0,
quantity_owned: r.get(2)?,
card: CardRef {
oracle_id: r.get(3)?,
name: r.get(4)?,
mana_value: r.get(5)?,
colors: ColorSet(r.get::<_, i64>(6)? as u8),
color_identity: ColorSet(r.get::<_, i64>(7)? as u8),
type_line: r.get(8)?,
image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
},
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
/// The decks a given card is currently assigned to.
pub fn card_decks(&self, oracle_id: &str) -> Result<Vec<Deck>> {
let mut stmt = self.conn.prepare(
"SELECT d.id, d.name, d.format, d.commander_oracle_id, d.notes
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
WHERE dc.oracle_id = ?1
ORDER BY d.name COLLATE NOCASE",
)?;
let rows = stmt
.query_map(params![oracle_id], Self::map_deck)?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
fn map_deck(r: &rusqlite::Row) -> rusqlite::Result<Deck> {
Ok(Deck {
id: r.get(0)?,
name: r.get(1)?,
format: r.get(2)?,
commander_oracle_id: r.get(3)?,
notes: r.get(4)?,
})
}
/// Set the deck's format (e.g. "Commander"). Empty string clears it.
pub fn set_deck_format(&self, deck_id: i64, format: &str) -> Result<()> {
self.conn.execute(
"UPDATE decks SET format = ?1 WHERE id = ?2",
params![format, deck_id],
)?;
Ok(())
}
/// Set (or clear, with `None`) the deck's commander card. Also marks/unmarks
/// the corresponding deck_cards row as the commander when present.
pub fn set_deck_commander(&self, deck_id: i64, oracle_id: Option<&str>) -> Result<()> {
self.conn.execute(
"UPDATE decks SET commander_oracle_id = ?1 WHERE id = ?2",
params![oracle_id, deck_id],
)?;
// Reset any existing commander flag, then set it on the chosen card.
self.conn.execute(
"UPDATE deck_cards SET is_commander = 0 WHERE deck_id = ?1",
params![deck_id],
)?;
if let Some(oid) = oracle_id {
self.conn.execute(
"UPDATE deck_cards SET is_commander = 1 WHERE deck_id = ?1 AND oracle_id = ?2",
params![deck_id, oid],
)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::test_support;
#[test]
fn deck_membership_visible_from_library() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("llanowar-elves", 4).unwrap();
let elves = cat.create_deck("Elfball", Some("llanowar-elves")).unwrap();
cat.set_deck_card(elves.id, "llanowar-elves", 4, false)
.unwrap();
// card_decks reports membership.
let decks = cat.card_decks("llanowar-elves").unwrap();
assert_eq!(decks.len(), 1);
assert_eq!(decks[0].name, "Elfball");
// list_library surfaces the deck name on the card row.
let lib = cat.list_library().unwrap();
let row = lib
.iter()
.find(|c| c.card.oracle_id == "llanowar-elves")
.unwrap();
assert_eq!(row.decks, vec!["Elfball".to_string()]);
assert_eq!(row.quantity_assigned, 4);
assert!(!row.is_over_assigned());
}
#[test]
fn over_assignment_detected_across_decks() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap(); // own one physical copy
let a = cat.create_deck("Deck A", None).unwrap();
let b = cat.create_deck("Deck B", None).unwrap();
cat.set_deck_card(a.id, "sol-ring", 1, false).unwrap();
cat.set_deck_card(b.id, "sol-ring", 1, false).unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.quantity_assigned, 2);
assert_eq!(sol.quantity_owned, 1);
assert!(sol.is_over_assigned());
assert_eq!(sol.decks.len(), 2);
}
#[test]
fn format_and_commander_metadata() {
let fx = test_support::make();
let cat = &fx.catalog;
let d = cat.create_deck("EDH", None).unwrap();
assert_eq!(d.format, "");
cat.set_deck_format(d.id, "Commander").unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 1, false).unwrap();
cat.set_deck_commander(d.id, Some("llanowar-elves")).unwrap();
let d = cat.get_deck(d.id).unwrap().unwrap();
assert_eq!(d.format, "Commander");
assert_eq!(d.commander_oracle_id.as_deref(), Some("llanowar-elves"));
// The card is flagged as commander in the deck.
let commander = cat
.deck_contents(d.id)
.unwrap()
.into_iter()
.find(|e| e.card.oracle_id == "llanowar-elves")
.unwrap();
assert!(commander.is_commander);
// Clearing works.
cat.set_deck_commander(d.id, None).unwrap();
assert_eq!(
cat.get_deck(d.id).unwrap().unwrap().commander_oracle_id,
None
);
}
#[test]
fn deck_contents_include_owned_and_unowned() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("llanowar-elves", 4).unwrap(); // owned
// serra-angel intentionally NOT owned.
let d = cat.create_deck("Mixed", None).unwrap();
cat.set_deck_card(d.id, "llanowar-elves", 4, false).unwrap();
cat.set_deck_card(d.id, "serra-angel", 1, true).unwrap();
let contents = cat.deck_contents(d.id).unwrap();
assert_eq!(contents.len(), 2);
let owned: Vec<_> = contents.iter().filter(|e| e.quantity_owned > 0).collect();
let unowned: Vec<_> = contents.iter().filter(|e| e.quantity_owned == 0).collect();
assert_eq!(owned.len(), 1);
assert_eq!(owned[0].card.name, "Llanowar Elves");
assert_eq!(owned[0].quantity, 4);
assert_eq!(unowned.len(), 1);
assert_eq!(unowned[0].card.name, "Serra Angel");
assert!(unowned[0].is_commander);
}
#[test]
fn setting_zero_removes_and_cascade_on_delete() {
let fx = test_support::make();
let cat = &fx.catalog;
let d = cat.create_deck("Temp", None).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 2, false).unwrap();
cat.set_deck_card(d.id, "lightning-bolt", 0, false).unwrap();
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
cat.set_deck_card(d.id, "lightning-bolt", 1, false).unwrap();
cat.delete_deck(d.id).unwrap();
assert!(cat.card_decks("lightning-bolt").unwrap().is_empty());
}
}

View File

@@ -0,0 +1,29 @@
//! Core error type.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum CoreError {
#[error(transparent)]
Sqlite(#[from] rusqlite::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("http error: {0}")]
Http(String),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("{0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, CoreError>;
impl From<ureq::Error> for CoreError {
fn from(e: ureq::Error) -> Self {
CoreError::Http(e.to_string())
}
}

View File

@@ -0,0 +1,228 @@
//! Criteria search over the owned library, compiled to SQL across the library
//! and attached reference dbs.
use rusqlite::types::Value;
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{LibraryCard, LibraryFilter};
use crate::Catalog;
impl Catalog {
/// Search the owned library by the given criteria. An empty filter returns
/// the whole owned library (same set as [`Catalog::list_library`]).
///
/// Semantics: within a facet, multiple `types`/`subtypes`/`keywords` values
/// are ANDed (the card must have all of them). Commander compatibility keeps
/// only cards whose color identity is a subset of the given identity.
pub fn search_library(&self, filter: &LibraryFilter) -> Result<Vec<LibraryCard>> {
let mut sql = format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0",
image = crate::db::image_subquery()
);
let mut vals: Vec<Value> = Vec::new();
if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
sql.push_str(" AND oc.name_lower LIKE ?");
vals.push(Value::Text(format!("%{}%", text.to_lowercase())));
}
if let Some(c) = filter.colors_exact {
sql.push_str(" AND oc.colors = ?");
vals.push(Value::Integer(c.0 as i64));
}
if let Some(c) = filter.colors_include {
sql.push_str(" AND (oc.colors & ?) = ?");
vals.push(Value::Integer(c.0 as i64));
vals.push(Value::Integer(c.0 as i64));
}
if let Some(id) = filter.commander_identity {
// subset: card_identity & ~commander == 0 (see ColorSet::is_subset_of)
sql.push_str(" AND (oc.color_identity & ?) = 0");
vals.push(Value::Integer((!id.0 & 0b11111) as i64));
}
if let Some(min) = filter.cmc_min {
sql.push_str(" AND oc.mana_value >= ?");
vals.push(Value::Real(min));
}
if let Some(max) = filter.cmc_max {
sql.push_str(" AND oc.mana_value <= ?");
vals.push(Value::Real(max));
}
for t in &filter.types {
sql.push_str(&format!(
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_types x
WHERE x.oracle_id = oc.oracle_id AND x.type = ? COLLATE NOCASE)"
));
vals.push(Value::Text(t.clone()));
}
for s in &filter.subtypes {
sql.push_str(&format!(
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_subtypes x
WHERE x.oracle_id = oc.oracle_id AND x.subtype = ? COLLATE NOCASE)"
));
vals.push(Value::Text(s.clone()));
}
for k in &filter.keywords {
sql.push_str(&format!(
" AND EXISTS (SELECT 1 FROM {REF_DB}.card_keywords x
WHERE x.oracle_id = oc.oracle_id AND x.keyword = ? COLLATE NOCASE)"
));
vals.push(Value::Text(k.clone()));
}
if let Some(deck_id) = filter.in_deck {
sql.push_str(
" AND EXISTS (SELECT 1 FROM deck_cards dc
WHERE dc.oracle_id = oc.oracle_id AND dc.deck_id = ?)",
);
vals.push(Value::Integer(deck_id));
}
sql.push_str(" ORDER BY oc.name COLLATE NOCASE");
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt
.query_map(
rusqlite::params_from_iter(vals),
crate::library::row_to_library_parts,
)?
.collect::<std::result::Result<Vec<_>, _>>()?;
// Enrich with deck context (shared with list_library).
let deck_names = self.deck_names_by_oracle()?;
let assigned = self.assigned_totals_by_oracle()?;
Ok(rows
.into_iter()
.map(|(oid, qty, location, card)| LibraryCard {
quantity_owned: qty,
decks: deck_names.get(&oid).cloned().unwrap_or_default(),
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
location,
card,
})
.collect())
}
}
#[cfg(test)]
mod tests {
use crate::model::{ColorSet, LibraryFilter};
use crate::test_support;
/// Own the whole fixture set so filters have something to select from.
fn owned_fixture() -> test_support::Fixture {
let fx = test_support::make();
for c in test_support::seed_cards() {
fx.catalog.add_owned(c.oracle_id, 1).unwrap();
}
fx
}
fn names(cards: &[crate::model::LibraryCard]) -> Vec<String> {
cards.iter().map(|c| c.card.name.clone()).collect()
}
#[test]
fn empty_filter_returns_all_owned() {
let fx = owned_fixture();
let all = fx.catalog.search_library(&LibraryFilter::default()).unwrap();
assert_eq!(all.len(), 5);
}
#[test]
fn commander_identity_subset() {
let fx = owned_fixture();
// A B/G commander: Golgari (BG) and mono-B/mono-G and colorless are legal;
// white/red cards are not.
let f = LibraryFilter {
commander_identity: Some(ColorSet::parse("BG")),
..Default::default()
};
let got = names(&fx.catalog.search_library(&f).unwrap());
assert!(got.contains(&"Deathrite Shaman".to_string())); // BG
assert!(got.contains(&"Llanowar Elves".to_string())); // G
assert!(got.contains(&"Sol Ring".to_string())); // colorless
assert!(!got.contains(&"Serra Angel".to_string())); // W
assert!(!got.contains(&"Lightning Bolt".to_string())); // R
// Mono-white commander: the BG card is illegal.
let f = LibraryFilter {
commander_identity: Some(ColorSet::parse("W")),
..Default::default()
};
let got = names(&fx.catalog.search_library(&f).unwrap());
assert!(got.contains(&"Serra Angel".to_string()));
assert!(!got.contains(&"Deathrite Shaman".to_string()));
}
#[test]
fn type_subtype_keyword_and_cmc() {
let fx = owned_fixture();
let creatures = names(&fx.catalog.search_library(&LibraryFilter {
types: vec!["Creature".into()],
..Default::default()
}).unwrap());
assert_eq!(creatures.len(), 3); // Llanowar, Deathrite, Serra
let elves = names(&fx.catalog.search_library(&LibraryFilter {
subtypes: vec!["Elf".into()],
..Default::default()
}).unwrap());
assert_eq!(elves, vec!["Deathrite Shaman", "Llanowar Elves"]);
let flyers = names(&fx.catalog.search_library(&LibraryFilter {
keywords: vec!["Flying".into()],
..Default::default()
}).unwrap());
assert_eq!(flyers, vec!["Serra Angel"]);
let cheap = fx.catalog.search_library(&LibraryFilter {
cmc_max: Some(1.0),
..Default::default()
}).unwrap();
assert!(cheap.iter().all(|c| c.card.mana_value <= 1.0));
assert!(!cheap.iter().any(|c| c.card.name == "Serra Angel"));
}
#[test]
fn colors_exact_and_include() {
let fx = owned_fixture();
let exact_g = names(&fx.catalog.search_library(&LibraryFilter {
colors_exact: Some(ColorSet::parse("G")),
..Default::default()
}).unwrap());
assert_eq!(exact_g, vec!["Llanowar Elves"]); // not Deathrite (BG)
let has_g = names(&fx.catalog.search_library(&LibraryFilter {
colors_include: Some(ColorSet::parse("G")),
..Default::default()
}).unwrap());
assert!(has_g.contains(&"Llanowar Elves".to_string()));
assert!(has_g.contains(&"Deathrite Shaman".to_string()));
}
#[test]
fn text_and_in_deck() {
let fx = owned_fixture();
let cat = &fx.catalog;
let bolt = names(&cat.search_library(&LibraryFilter {
text: Some("bolt".into()),
..Default::default()
}).unwrap());
assert_eq!(bolt, vec!["Lightning Bolt"]);
let deck = cat.create_deck("Mono-R", None).unwrap();
cat.set_deck_card(deck.id, "lightning-bolt", 1, false).unwrap();
let in_deck = names(&cat.search_library(&LibraryFilter {
in_deck: Some(deck.id),
..Default::default()
}).unwrap());
assert_eq!(in_deck, vec!["Lightning Bolt"]);
}
}

View File

@@ -0,0 +1,448 @@
//! Build `reference.db` from Scryfall bulk data.
//!
//! Two passes: the *Oracle Cards* bulk (one entry per `oracle_id`) fills
//! `oracle_cards` + the normalized type/subtype/keyword tables + the name FTS;
//! the *Default Cards* bulk (one entry per printing) fills `printings`.
//!
//! Both bulk files are large JSON arrays, so we stream them element-by-element
//! rather than loading the whole array into memory.
use std::io::{BufReader, Read};
use std::marker::PhantomData;
use std::path::Path;
use rusqlite::{params, Connection};
use serde::de::{DeserializeOwned, SeqAccess, Visitor};
use serde::Deserialize;
use crate::db::open_reference_for_build;
use crate::error::{CoreError, Result};
use crate::model::ColorSet;
const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data";
const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)";
/// Row counts from a build, for reporting/verification.
#[derive(Debug, Default, Clone, Copy)]
pub struct IngestStats {
pub oracle_cards: usize,
pub printings: usize,
}
/// Download the latest Scryfall bulk data and (re)build `reference.db` at `dest`.
///
/// `progress` is called with human-readable status lines.
pub fn build_reference_db(dest: &Path, mut progress: impl FnMut(&str)) -> Result<IngestStats> {
let (oracle_uri, default_uri) = fetch_bulk_uris(&mut progress)?;
if dest.exists() {
std::fs::remove_file(dest)?;
}
let conn = open_reference_for_build(dest)?;
// Bulk-load fast; this db is disposable/rebuildable so durability is moot.
conn.pragma_update(None, "journal_mode", "MEMORY")?;
conn.pragma_update(None, "synchronous", "OFF")?;
progress("Loading oracle cards…");
let oracle_cards = load_oracle_cards(&conn, http_reader(&oracle_uri)?)?;
progress("Loading printings…");
let printings = load_printings(&conn, http_reader(&default_uri)?)?;
progress("Optimizing…");
conn.execute_batch("INSERT INTO oracle_names_fts(oracle_names_fts) VALUES('optimize');")?;
Ok(IngestStats {
oracle_cards,
printings,
})
}
// ---- Scryfall bulk index -------------------------------------------------
#[derive(Deserialize)]
struct BulkIndex {
data: Vec<BulkEntry>,
}
#[derive(Deserialize)]
struct BulkEntry {
#[serde(rename = "type")]
kind: String,
download_uri: String,
}
fn fetch_bulk_uris(progress: &mut impl FnMut(&str)) -> Result<(String, String)> {
progress("Fetching Scryfall bulk index…");
let index: BulkIndex = ureq::get(BULK_INDEX_URL)
.set("User-Agent", USER_AGENT)
.set("Accept", "application/json")
.call()?
.into_json()?;
let find = |kind: &str| {
index
.data
.iter()
.find(|e| e.kind == kind)
.map(|e| e.download_uri.clone())
.ok_or_else(|| CoreError::Other(format!("Scryfall bulk index missing '{kind}'")))
};
Ok((find("oracle_cards")?, find("default_cards")?))
}
fn http_reader(url: &str) -> Result<impl Read> {
let resp = ureq::get(url)
.set("User-Agent", USER_AGENT)
.call()?;
Ok(BufReader::with_capacity(1 << 20, resp.into_reader()))
}
// ---- Streaming array parse ----------------------------------------------
/// Deserialize a top-level JSON array element-by-element, invoking `f` per item
/// without holding the whole array in memory.
fn stream_json_array<R, T, F>(reader: R, f: F) -> Result<()>
where
R: Read,
T: DeserializeOwned,
F: FnMut(T) -> Result<()>,
{
struct SeqVisitor<T, F> {
f: F,
_marker: PhantomData<fn() -> T>,
}
impl<'de, T, F> Visitor<'de> for SeqVisitor<T, F>
where
T: DeserializeOwned,
F: FnMut(T) -> Result<()>,
{
type Value = ();
fn expecting(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
fmt.write_str("a JSON array of objects")
}
fn visit_seq<A>(mut self, mut seq: A) -> std::result::Result<(), A::Error>
where
A: SeqAccess<'de>,
{
while let Some(item) = seq.next_element::<T>()? {
(self.f)(item).map_err(serde::de::Error::custom)?;
}
Ok(())
}
}
let mut de = serde_json::Deserializer::from_reader(reader);
serde::de::Deserializer::deserialize_seq(
&mut de,
SeqVisitor {
f,
_marker: PhantomData,
},
)?;
Ok(())
}
// ---- Oracle pass ---------------------------------------------------------
#[derive(Deserialize)]
struct ScryOracle {
oracle_id: Option<String>,
name: String,
#[serde(default)]
cmc: f64,
#[serde(default)]
colors: Option<Vec<String>>,
#[serde(default)]
color_identity: Vec<String>,
#[serde(default)]
type_line: Option<String>,
#[serde(default)]
oracle_text: Option<String>,
#[serde(default)]
keywords: Vec<String>,
#[serde(default)]
card_faces: Option<Vec<ScryFace>>,
}
#[derive(Deserialize)]
struct ScryFace {
#[serde(default)]
type_line: Option<String>,
#[serde(default)]
colors: Option<Vec<String>>,
}
fn load_oracle_cards(conn: &Connection, reader: impl Read) -> Result<usize> {
conn.execute_batch("BEGIN")?;
let mut count = 0usize;
{
let mut ins_card = conn.prepare(
"INSERT OR IGNORE INTO oracle_cards
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
)?;
let mut ins_fts =
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
let mut ins_type =
conn.prepare("INSERT INTO card_types(oracle_id,type) VALUES (?1,?2)")?;
let mut ins_sub =
conn.prepare("INSERT INTO card_subtypes(oracle_id,subtype) VALUES (?1,?2)")?;
let mut ins_kw =
conn.prepare("INSERT INTO card_keywords(oracle_id,keyword) VALUES (?1,?2)")?;
stream_json_array::<_, ScryOracle, _>(reader, |c| {
let Some(oracle_id) = c.oracle_id.clone() else {
return Ok(()); // rare face-only records without a top-level oracle_id
};
// Colors: prefer top-level, else union of faces.
let colors = match &c.colors {
Some(cs) => ColorSet::from_letters(cs),
None => {
let mut m = 0u8;
if let Some(faces) = &c.card_faces {
for f in faces {
if let Some(cs) = &f.colors {
m |= ColorSet::from_letters(cs).0;
}
}
}
ColorSet(m)
}
};
let identity = ColorSet::from_letters(&c.color_identity);
// Type line: top-level, else join faces.
let type_line = c.type_line.clone().unwrap_or_else(|| {
c.card_faces
.as_ref()
.map(|faces| {
faces
.iter()
.filter_map(|f| f.type_line.clone())
.collect::<Vec<_>>()
.join(" // ")
})
.unwrap_or_default()
});
let changed = ins_card.execute(params![
oracle_id,
c.name,
c.name.to_lowercase(),
c.cmc,
colors.0 as i64,
identity.0 as i64,
type_line,
c.oracle_text.clone().unwrap_or_default(),
])?;
// Only emit satellite rows the first time we see this oracle_id.
if changed == 1 {
ins_fts.execute(params![c.name, oracle_id])?;
let (types, subtypes) = parse_type_line(&type_line);
for t in types {
ins_type.execute(params![oracle_id, t])?;
}
for s in subtypes {
ins_sub.execute(params![oracle_id, s])?;
}
let mut kws = c.keywords.clone();
kws.sort();
kws.dedup();
for k in kws {
ins_kw.execute(params![oracle_id, k])?;
}
count += 1;
}
Ok(())
})?;
}
conn.execute_batch("COMMIT")?;
Ok(count)
}
/// Split a Scryfall type line into (types+supertypes, subtypes). Handles split /
/// double-faced lines joined by " // ". The separator is the em dash '—'.
pub fn parse_type_line(type_line: &str) -> (Vec<String>, Vec<String>) {
let mut types = Vec::new();
let mut subtypes = Vec::new();
for part in type_line.split("//") {
let part = part.trim();
let mut halves = part.splitn(2, '—');
let left = halves.next().unwrap_or("").trim();
let right = halves.next().unwrap_or("").trim();
for t in left.split_whitespace() {
types.push(t.to_string());
}
for s in right.split_whitespace() {
subtypes.push(s.to_string());
}
}
types.sort();
types.dedup();
subtypes.sort();
subtypes.dedup();
(types, subtypes)
}
// ---- Printings pass ------------------------------------------------------
#[derive(Deserialize)]
struct ScryPrinting {
id: String,
oracle_id: Option<String>,
#[serde(default)]
set: String,
#[serde(default)]
collector_number: String,
#[serde(default)]
finishes: Vec<String>,
#[serde(default)]
rarity: String,
#[serde(default)]
image_uris: Option<ImageUris>,
#[serde(default)]
card_faces: Option<Vec<PrintFace>>,
}
#[derive(Deserialize)]
struct ImageUris {
#[serde(default)]
normal: Option<String>,
}
#[derive(Deserialize)]
struct PrintFace {
#[serde(default)]
image_uris: Option<ImageUris>,
}
fn load_printings(conn: &Connection, reader: impl Read) -> Result<usize> {
conn.execute_batch("BEGIN")?;
let mut count = 0usize;
{
let mut ins = conn.prepare(
"INSERT OR IGNORE INTO printings
(scryfall_id,oracle_id,set_code,collector_number,finishes,rarity,image_uri)
VALUES (?1,?2,?3,?4,?5,?6,?7)",
)?;
stream_json_array::<_, ScryPrinting, _>(reader, |p| {
let Some(oracle_id) = p.oracle_id.clone() else {
return Ok(());
};
let image = p
.image_uris
.as_ref()
.and_then(|i| i.normal.clone())
.or_else(|| {
p.card_faces
.as_ref()
.and_then(|f| f.first())
.and_then(|f| f.image_uris.as_ref())
.and_then(|i| i.normal.clone())
})
.unwrap_or_default();
ins.execute(params![
p.id,
oracle_id,
p.set,
p.collector_number,
p.finishes.join(","),
p.rarity,
image,
])?;
count += 1;
Ok(())
})?;
}
conn.execute_batch("COMMIT")?;
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_type_lines() {
let (t, s) = parse_type_line("Legendary Creature — Elf Druid");
assert_eq!(t, vec!["Creature", "Legendary"]);
assert_eq!(s, vec!["Druid", "Elf"]);
let (t, s) = parse_type_line("Instant");
assert_eq!(t, vec!["Instant"]);
assert!(s.is_empty());
// Split card.
let (t, s) = parse_type_line("Sorcery // Sorcery");
assert_eq!(t, vec!["Sorcery"]);
assert!(s.is_empty());
}
#[test]
fn streams_array_of_objects() {
#[derive(serde::Deserialize)]
struct Item {
n: i32,
}
let json = br#"[{"n":1},{"n":2},{"n":3}]"#;
let mut sum = 0;
stream_json_array::<_, Item, _>(&json[..], |it| {
sum += it.n;
Ok(())
})
.unwrap();
assert_eq!(sum, 6);
}
#[test]
fn oracle_and_printing_load_from_fixture_json() {
// Minimal Scryfall-shaped fixtures exercising the insert paths.
let oracle = r#"[
{"oracle_id":"oid-1","name":"Llanowar Elves","cmc":1.0,
"colors":["G"],"color_identity":["G"],
"type_line":"Creature — Elf Druid","keywords":[]},
{"oracle_id":"oid-2","name":"Serra Angel","cmc":5.0,
"colors":["W"],"color_identity":["W"],
"type_line":"Creature — Angel","keywords":["Flying","Vigilance"]}
]"#;
let printings = r#"[
{"id":"p-1","oracle_id":"oid-1","set":"m19","collector_number":"310",
"finishes":["nonfoil","foil"],"rarity":"common",
"image_uris":{"normal":"http://img/1"}}
]"#;
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("reference.db");
let conn = open_reference_for_build(&dest).unwrap();
let oc = load_oracle_cards(&conn, oracle.as_bytes()).unwrap();
let pc = load_printings(&conn, printings.as_bytes()).unwrap();
assert_eq!(oc, 2);
assert_eq!(pc, 1);
// Keyword table populated.
let flyers: i64 = conn
.query_row(
"SELECT count(*) FROM card_keywords WHERE keyword='Flying'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(flyers, 1);
// Bitmask colors stored.
let g: i64 = conn
.query_row(
"SELECT colors FROM oracle_cards WHERE oracle_id='oid-1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(g as u8, crate::model::G);
}
}

217
crates/mtgc-core/src/lib.rs Normal file
View File

@@ -0,0 +1,217 @@
//! mtgc-core — platform-agnostic core for the MTG library catalog.
//!
//! Everything that isn't UI lives here: the SQLite schema/migrations, the
//! in-memory fuzzy name search, the criteria filters, deck operations, and the
//! Scryfall ingester. It has no Tauri (or any GUI) dependency so it stays
//! unit-testable and reusable when the app is ported to mobile.
pub mod db;
pub mod decks;
pub mod error;
pub mod filters;
pub mod ingest;
pub mod library;
pub mod model;
pub mod search;
use std::path::Path;
use rusqlite::Connection;
pub use error::{CoreError, Result};
pub use model::{CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFilter};
pub use search::NameIndex;
/// A handle to an open catalog: the library db with the reference db attached.
///
/// All read/write operations go through this. It owns the SQLite connection;
/// the fuzzy [`NameIndex`] is built separately (see [`Catalog::build_name_index`])
/// so the caller can cache it in app state.
pub struct Catalog {
pub(crate) conn: Connection,
}
impl Catalog {
/// Open the library at `library_path` (running migrations) with the
/// reference db at `reference_path` attached.
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Self> {
let conn = db::open(library_path, reference_path)?;
Ok(Self { conn })
}
/// Build (or rebuild) the in-memory fuzzy name index from the attached
/// reference db. Cheap enough to hold in app state and rebuild after a
/// reference-data update.
pub fn build_name_index(&self) -> Result<NameIndex> {
NameIndex::build(&self.conn)
}
/// Borrow the underlying connection (used by tests and advanced callers).
pub fn connection(&self) -> &Connection {
&self.conn
}
}
#[cfg(test)]
pub(crate) mod test_support {
//! Builds a tiny library+reference catalog in temp files for tests.
use super::*;
use crate::model::ColorSet;
pub struct Fixture {
pub catalog: Catalog,
// Keep the temp dir alive for the lifetime of the fixture.
_dir: tempfile::TempDir,
}
/// (oracle_id, name, mana_value, colors, color_identity, type_line,
/// types, subtypes, keywords)
pub struct SeedCard {
pub oracle_id: &'static str,
pub name: &'static str,
pub mv: f64,
pub colors: &'static str,
pub identity: &'static str,
pub type_line: &'static str,
pub types: &'static [&'static str],
pub subtypes: &'static [&'static str],
pub keywords: &'static [&'static str],
}
pub fn seed_cards() -> Vec<SeedCard> {
vec![
SeedCard {
oracle_id: "llanowar-elves",
name: "Llanowar Elves",
mv: 1.0,
colors: "G",
identity: "G",
type_line: "Creature — Elf Druid",
types: &["Creature"],
subtypes: &["Elf", "Druid"],
keywords: &[],
},
SeedCard {
oracle_id: "deathrite-shaman",
name: "Deathrite Shaman",
mv: 1.0,
colors: "BG",
identity: "BG",
type_line: "Creature — Elf Shaman",
types: &["Creature"],
subtypes: &["Elf", "Shaman"],
keywords: &[],
},
SeedCard {
oracle_id: "serra-angel",
name: "Serra Angel",
mv: 5.0,
colors: "W",
identity: "W",
type_line: "Creature — Angel",
types: &["Creature"],
subtypes: &["Angel"],
keywords: &["Flying", "Vigilance"],
},
SeedCard {
oracle_id: "lightning-bolt",
name: "Lightning Bolt",
mv: 1.0,
colors: "R",
identity: "R",
type_line: "Instant",
types: &["Instant"],
subtypes: &[],
keywords: &[],
},
SeedCard {
oracle_id: "sol-ring",
name: "Sol Ring",
mv: 1.0,
colors: "",
identity: "",
type_line: "Artifact",
types: &["Artifact"],
subtypes: &[],
keywords: &[],
},
]
}
pub fn make() -> Fixture {
let dir = tempfile::tempdir().unwrap();
let ref_path = dir.path().join("reference.db");
let lib_path = dir.path().join("library.db");
// Build reference.db with fixture cards.
{
let rconn = db::open_reference_for_build(&ref_path).unwrap();
for c in seed_cards() {
let colors = ColorSet::parse(c.colors).0 as i64;
let identity = ColorSet::parse(c.identity).0 as i64;
rconn
.execute(
"INSERT INTO oracle_cards
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line)
VALUES (?1,?2,?3,?4,?5,?6,?7)",
rusqlite::params![
c.oracle_id,
c.name,
c.name.to_lowercase(),
c.mv,
colors,
identity,
c.type_line
],
)
.unwrap();
rconn
.execute(
"INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)",
rusqlite::params![c.name, c.oracle_id],
)
.unwrap();
for t in c.types {
rconn
.execute(
"INSERT INTO card_types(oracle_id,type) VALUES (?1,?2)",
rusqlite::params![c.oracle_id, t],
)
.unwrap();
}
for s in c.subtypes {
rconn
.execute(
"INSERT INTO card_subtypes(oracle_id,subtype) VALUES (?1,?2)",
rusqlite::params![c.oracle_id, s],
)
.unwrap();
}
for k in c.keywords {
rconn
.execute(
"INSERT INTO card_keywords(oracle_id,keyword) VALUES (?1,?2)",
rusqlite::params![c.oracle_id, k],
)
.unwrap();
}
// One printing per card, carrying an image URL.
rconn
.execute(
"INSERT INTO printings(scryfall_id,oracle_id,image_uri)
VALUES (?1,?2,?3)",
rusqlite::params![
format!("print-{}", c.oracle_id),
c.oracle_id,
format!("http://img/{}", c.oracle_id)
],
)
.unwrap();
}
}
let catalog = Catalog::open(&lib_path, &ref_path).unwrap();
Fixture { catalog, _dir: dir }
}
}

View File

@@ -0,0 +1,192 @@
//! Ownership operations: what cards the user has and how many.
use std::collections::HashMap;
use rusqlite::{params, OptionalExtension};
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet, LibraryCard};
use crate::Catalog;
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
/// colors, color_identity, type_line, image_uri)` into its library parts. Shared
/// by [`Catalog::list_library`] and the criteria search.
pub(crate) fn row_to_library_parts(
r: &rusqlite::Row,
) -> rusqlite::Result<(String, i64, String, CardRef)> {
let oracle_id: String = r.get(0)?;
Ok((
oracle_id.clone(),
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
CardRef {
oracle_id,
name: r.get(3)?,
mana_value: r.get(4)?,
colors: ColorSet(r.get::<_, i64>(5)? as u8),
color_identity: ColorSet(r.get::<_, i64>(6)? as u8),
type_line: r.get(7)?,
image_uri: r.get::<_, Option<String>>(8)?.unwrap_or_default(),
},
))
}
impl Catalog {
/// Add `delta` copies of a card to the library (increment, creating the row
/// if needed). `delta` may be negative to decrement; the quantity floors at 0.
/// Returns the new owned quantity.
pub fn add_owned(&self, oracle_id: &str, delta: i64) -> Result<i64> {
let current: i64 = self
.conn
.query_row(
"SELECT quantity_owned FROM library_cards WHERE oracle_id = ?1",
params![oracle_id],
|r| r.get(0),
)
.optional()?
.unwrap_or(0);
self.set_owned(oracle_id, current + delta)
}
/// Set the absolute owned quantity for a card (0 removes ownership but keeps
/// any deck assignments referencing it).
pub fn set_owned(&self, oracle_id: &str, quantity: i64) -> Result<i64> {
let q = quantity.max(0);
self.conn.execute(
"INSERT INTO library_cards(oracle_id, quantity_owned) VALUES (?1, ?2)
ON CONFLICT(oracle_id) DO UPDATE SET quantity_owned = excluded.quantity_owned",
params![oracle_id, q],
)?;
Ok(q)
}
/// List every owned card (quantity_owned > 0) with deck context, ordered by
/// name. This is the "browse my library" view without extra criteria.
pub fn list_library(&self) -> Result<Vec<LibraryCard>> {
let deck_names = self.deck_names_by_oracle()?;
let assigned = self.assigned_totals_by_oracle()?;
let mut stmt = self.conn.prepare(&format!(
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}
FROM library_cards lc
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
WHERE lc.quantity_owned > 0
ORDER BY oc.name COLLATE NOCASE",
image = crate::db::image_subquery()
))?;
let rows = stmt
.query_map([], row_to_library_parts)?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows
.into_iter()
.map(|(oid, qty, location, card)| LibraryCard {
quantity_owned: qty,
decks: deck_names.get(&oid).cloned().unwrap_or_default(),
quantity_assigned: *assigned.get(&oid).unwrap_or(&0),
location,
card,
})
.collect())
}
/// Set the free-text storage location for a card (creates the library row if
/// absent). Empty string clears it.
pub fn set_location(&self, oracle_id: &str, location: &str) -> Result<()> {
self.conn.execute(
"INSERT INTO library_cards(oracle_id, location) VALUES (?1, ?2)
ON CONFLICT(oracle_id) DO UPDATE SET location = excluded.location",
params![oracle_id, location],
)?;
Ok(())
}
/// Map each owned/assigned oracle_id to the deck names it appears in.
pub(crate) fn deck_names_by_oracle(&self) -> Result<HashMap<String, Vec<String>>> {
let mut stmt = self.conn.prepare(
"SELECT dc.oracle_id, d.name
FROM deck_cards dc JOIN decks d ON d.id = dc.deck_id
ORDER BY d.name COLLATE NOCASE",
)?;
let mut map: HashMap<String, Vec<String>> = HashMap::new();
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?;
for row in rows {
let (oid, name) = row?;
map.entry(oid).or_default().push(name);
}
Ok(map)
}
pub(crate) fn assigned_totals_by_oracle(&self) -> Result<HashMap<String, i64>> {
let mut stmt = self
.conn
.prepare("SELECT oracle_id, SUM(quantity) FROM deck_cards GROUP BY oracle_id")?;
let mut map = HashMap::new();
let rows = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
for row in rows {
let (oid, total) = row?;
map.insert(oid, total);
}
Ok(map)
}
}
#[cfg(test)]
mod tests {
use crate::test_support;
#[test]
fn add_increment_and_floor() {
let fx = test_support::make();
let cat = &fx.catalog;
assert_eq!(cat.add_owned("sol-ring", 1).unwrap(), 1);
assert_eq!(cat.add_owned("sol-ring", 2).unwrap(), 3);
assert_eq!(cat.add_owned("sol-ring", -10).unwrap(), 0); // floors at 0
assert_eq!(cat.set_owned("sol-ring", 4).unwrap(), 4);
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.quantity_owned, 4);
assert!(sol.decks.is_empty());
// Image URL is plumbed through from the printings table.
assert_eq!(sol.card.image_uri, "http://img/sol-ring");
}
#[test]
fn location_is_stored_and_listed() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("sol-ring", 1).unwrap();
cat.set_location("sol-ring", "Box 1").unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.location, "Box 1");
// Clearing works.
cat.set_location("sol-ring", "").unwrap();
let lib = cat.list_library().unwrap();
let sol = lib.iter().find(|c| c.card.oracle_id == "sol-ring").unwrap();
assert_eq!(sol.location, "");
}
#[test]
fn list_is_ordered_and_excludes_zero() {
let fx = test_support::make();
let cat = &fx.catalog;
cat.add_owned("serra-angel", 1).unwrap();
cat.add_owned("lightning-bolt", 1).unwrap();
cat.set_owned("sol-ring", 0).unwrap();
let names: Vec<_> = cat
.list_library()
.unwrap()
.into_iter()
.map(|c| c.card.name)
.collect();
assert_eq!(names, vec!["Lightning Bolt", "Serra Angel"]);
}
}

View File

@@ -0,0 +1,165 @@
//! Shared domain types used across the core.
use serde::{Deserialize, Serialize};
/// A card's color identity / colors, encoded as a 5-bit WUBRG mask.
///
/// Bit layout (little end first): W=1, U=2, B=4, R=8, G=16.
/// Storing colors this way turns commander color-identity compatibility into a
/// single integer predicate (see [`ColorSet::is_subset_of`]).
pub const W: u8 = 1 << 0;
pub const U: u8 = 1 << 1;
pub const B: u8 = 1 << 2;
pub const R: u8 = 1 << 3;
pub const G: u8 = 1 << 4;
/// The five colors in canonical WUBRG order, paired with their single-letter code.
pub const WUBRG: [(u8, char); 5] = [(W, 'W'), (U, 'U'), (B, 'B'), (R, 'R'), (G, 'G')];
/// A set of colors as a WUBRG bitmask.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ColorSet(pub u8);
impl ColorSet {
pub const COLORLESS: ColorSet = ColorSet(0);
/// Build from an iterator of single-letter color codes (e.g. Scryfall's
/// `["B", "G"]`). Unknown letters are ignored.
pub fn from_letters<I, S>(letters: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut mask = 0u8;
for l in letters {
match l.as_ref() {
"W" | "w" => mask |= W,
"U" | "u" => mask |= U,
"B" | "b" => mask |= B,
"R" | "r" => mask |= R,
"G" | "g" => mask |= G,
_ => {}
}
}
ColorSet(mask)
}
/// Parse from a compact string like "BG" or "wubrg". Order-insensitive.
pub fn parse(s: &str) -> Self {
Self::from_letters(s.chars().map(|c| c.to_string()))
}
/// Canonical WUBRG string, e.g. `ColorSet(B|G)` -> "BG". Colorless -> "".
pub fn to_letters(self) -> String {
WUBRG
.iter()
.filter(|(bit, _)| self.0 & bit != 0)
.map(|(_, ch)| *ch)
.collect()
}
pub fn is_colorless(self) -> bool {
self.0 == 0
}
pub fn contains(self, bit: u8) -> bool {
self.0 & bit != 0
}
/// True if every color in `self` is also in `other`.
///
/// This is the commander-legality rule: a card is playable in a commander of
/// color identity `C` iff `card_identity.is_subset_of(C)`.
pub fn is_subset_of(self, other: ColorSet) -> bool {
self.0 & !other.0 == 0
}
}
/// A card as returned by search / library queries. Oracle-level (one per
/// `oracle_id`), enriched with the caller's ownership + deck context where relevant.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardRef {
pub oracle_id: String,
pub name: String,
pub mana_value: f64,
pub colors: ColorSet,
pub color_identity: ColorSet,
pub type_line: String,
/// URL of a card image (Scryfall "normal" size) for one of its printings,
/// or empty if none is available.
pub image_uri: String,
}
/// A library row: a card the user owns, with quantity and its deck memberships.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LibraryCard {
#[serde(flatten)]
pub card: CardRef,
pub quantity_owned: i64,
/// Names of decks this card is currently assigned to.
pub decks: Vec<String>,
/// Total copies assigned across all decks. If it exceeds `quantity_owned`,
/// the physical copies are over-committed.
pub quantity_assigned: i64,
/// Free-text physical storage location (e.g. "Box 1"). Meaningful mainly for
/// cards not currently in a deck.
pub location: String,
}
/// A card in a deck, whether or not the user owns it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeckEntry {
#[serde(flatten)]
pub card: CardRef,
/// Copies of this card in the deck.
pub quantity: i64,
pub is_commander: bool,
/// Copies owned in the library (0 if the card isn't in the library).
pub quantity_owned: i64,
}
impl LibraryCard {
pub fn is_over_assigned(&self) -> bool {
self.quantity_assigned > self.quantity_owned
}
}
/// A deck.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Deck {
pub id: i64,
pub name: String,
/// Free-text format, e.g. "Commander", "Modern" (empty if unset).
pub format: String,
/// The commander card's oracle id (meaningful for Commander/Brawl formats).
pub commander_oracle_id: Option<String>,
pub notes: String,
}
/// Criteria for searching the owned library. All fields are optional; an empty
/// filter matches every owned card.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct LibraryFilter {
/// Fuzzy-ish name text (applied as a case-insensitive substring here; the
/// fast fuzzy path is [`crate::search`]).
pub text: Option<String>,
/// Cards whose `colors` exactly equal this set.
pub colors_exact: Option<ColorSet>,
/// Cards whose `colors` include at least these.
pub colors_include: Option<ColorSet>,
/// Commander compatibility: only cards whose color identity is a subset of
/// this set (playable under a commander of this identity).
pub commander_identity: Option<ColorSet>,
pub cmc_min: Option<f64>,
pub cmc_max: Option<f64>,
/// Card supertype/type, e.g. "Creature", "Instant" (matched against the
/// normalized `card_types` table).
pub types: Vec<String>,
/// Subtype, e.g. "Elf", "Equipment".
pub subtypes: Vec<String>,
/// Keyword ability, e.g. "Flying", "Deathtouch".
pub keywords: Vec<String>,
/// Restrict to cards assigned to this deck id.
pub in_deck: Option<i64>,
}

View File

@@ -0,0 +1,108 @@
//! In-memory fuzzy search over oracle card names (the "rapid entry" path).
//!
//! Names are loaded once from the reference db into a [`NameIndex`]; each query
//! is ranked with nucleo (fzf-quality), giving typo-tolerant ordering far better
//! than SQL `LIKE`. ~30k names rank in well under a millisecond.
use nucleo::pattern::{CaseMatching, Normalization, Pattern};
use nucleo::{Config, Matcher};
use rusqlite::Connection;
use crate::db::REF_DB;
use crate::error::Result;
use crate::model::{CardRef, ColorSet};
/// One searchable entry, carrying the full [`CardRef`] so a hit needs no extra
/// DB round-trip. `AsRef<str>` yields the name that nucleo matches against.
struct Entry {
card: CardRef,
}
impl AsRef<str> for Entry {
fn as_ref(&self) -> &str {
&self.card.name
}
}
/// An immutable, in-memory index of all oracle card names for fuzzy search.
pub struct NameIndex {
entries: Vec<Entry>,
}
impl NameIndex {
/// Load every oracle card from the attached reference db.
pub fn build(conn: &Connection) -> Result<Self> {
let mut stmt = conn.prepare(&format!(
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
oc.type_line, {image}
FROM {REF_DB}.oracle_cards oc",
image = crate::db::image_subquery()
))?;
let entries = stmt
.query_map([], |r| {
Ok(Entry {
card: CardRef {
oracle_id: r.get(0)?,
name: r.get(1)?,
mana_value: r.get(2)?,
colors: ColorSet(r.get::<_, i64>(3)? as u8),
color_identity: ColorSet(r.get::<_, i64>(4)? as u8),
type_line: r.get(5)?,
image_uri: r.get::<_, Option<String>>(6)?.unwrap_or_default(),
},
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(Self { entries })
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Rank cards by fuzzy match against `query`, returning up to `limit` of the
/// best matches (highest score first). An empty query returns nothing.
pub fn search(&self, query: &str, limit: usize) -> Vec<CardRef> {
if query.trim().is_empty() {
return Vec::new();
}
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut matches = pattern.match_list(self.entries.iter(), &mut matcher);
matches.truncate(limit);
matches.into_iter().map(|(e, _score)| e.card.clone()).collect()
}
}
#[cfg(test)]
mod tests {
use crate::test_support;
#[test]
fn typo_still_ranks_target_first() {
let fx = test_support::make();
let idx = fx.catalog.build_name_index().unwrap();
assert_eq!(idx.len(), 5);
// Misspelled "Llanowar Elves".
let hits = idx.search("lanowar elvs", 5);
assert!(!hits.is_empty(), "typo query should still match");
assert_eq!(hits[0].name, "Llanowar Elves");
}
#[test]
fn prefix_and_limit() {
let fx = test_support::make();
let idx = fx.catalog.build_name_index().unwrap();
let hits = idx.search("light", 5);
assert_eq!(hits[0].name, "Lightning Bolt");
assert!(idx.search("bolt", 1).len() <= 1);
assert!(idx.search("", 10).is_empty());
}
}

View File

@@ -0,0 +1,41 @@
-- library.db v1 — the small, portable, synced file.
-- References reference cards by stable oracle_id / scryfall_id.
CREATE TABLE decks (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
commander_oracle_id TEXT,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Oracle-level ownership: one row per distinct card.
CREATE TABLE library_cards (
id INTEGER PRIMARY KEY,
oracle_id TEXT NOT NULL UNIQUE,
quantity_owned INTEGER NOT NULL DEFAULT 0
);
-- Optional per-physical-copy printing detail (the "hybrid" identity).
CREATE TABLE library_card_copies (
id INTEGER PRIMARY KEY,
library_card_id INTEGER NOT NULL REFERENCES library_cards(id) ON DELETE CASCADE,
scryfall_id TEXT,
set_code TEXT,
collector_number TEXT,
finish TEXT,
condition TEXT
);
CREATE INDEX idx_copies_card ON library_card_copies(library_card_id);
-- Deck membership at the oracle level.
CREATE TABLE deck_cards (
id INTEGER PRIMARY KEY,
deck_id INTEGER NOT NULL REFERENCES decks(id) ON DELETE CASCADE,
oracle_id TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
is_commander INTEGER NOT NULL DEFAULT 0,
UNIQUE(deck_id, oracle_id)
);
CREATE INDEX idx_deck_cards_oid ON deck_cards(oracle_id);
CREATE INDEX idx_deck_cards_deck ON deck_cards(deck_id);

View File

@@ -0,0 +1,3 @@
-- v2 — physical storage location for owned cards (e.g. "Box 1", "Binder A").
-- Most relevant for cards not assigned to any deck.
ALTER TABLE library_cards ADD COLUMN location TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,3 @@
-- v3 — deck format metadata (e.g. "Commander", "Modern"). The commander card
-- itself is stored in the existing decks.commander_oracle_id column.
ALTER TABLE decks ADD COLUMN format TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,56 @@
-- Schema for reference.db: read-only card data built from Scryfall bulk.
-- Applied against a connection whose `main` database IS the reference db
-- (the ingester opens reference.db directly; at runtime it is ATTACHed as `ref`).
CREATE TABLE IF NOT EXISTS oracle_cards (
oracle_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
name_lower TEXT NOT NULL, -- for cheap case-insensitive substring
mana_value REAL NOT NULL DEFAULT 0,
colors INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
color_identity INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask
type_line TEXT NOT NULL DEFAULT '',
oracle_text TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
CREATE INDEX IF NOT EXISTS idx_oracle_ci ON oracle_cards(color_identity);
CREATE INDEX IF NOT EXISTS idx_oracle_mv ON oracle_cards(mana_value);
-- Normalized attribute tables for fast type/subtype/keyword filtering.
CREATE TABLE IF NOT EXISTS card_types (
oracle_id TEXT NOT NULL,
type TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_card_types_type ON card_types(type);
CREATE INDEX IF NOT EXISTS idx_card_types_oid ON card_types(oracle_id);
CREATE TABLE IF NOT EXISTS card_subtypes (
oracle_id TEXT NOT NULL,
subtype TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_card_subtypes_sub ON card_subtypes(subtype);
CREATE INDEX IF NOT EXISTS idx_card_subtypes_oid ON card_subtypes(oracle_id);
CREATE TABLE IF NOT EXISTS card_keywords (
oracle_id TEXT NOT NULL,
keyword TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_card_keywords_kw ON card_keywords(keyword);
CREATE INDEX IF NOT EXISTS idx_card_keywords_oid ON card_keywords(oracle_id);
-- One row per printing (default-language bulk).
CREATE TABLE IF NOT EXISTS printings (
scryfall_id TEXT PRIMARY KEY,
oracle_id TEXT NOT NULL,
set_code TEXT NOT NULL DEFAULT '',
collector_number TEXT NOT NULL DEFAULT '',
finishes TEXT NOT NULL DEFAULT '', -- comma-joined, e.g. "nonfoil,foil"
rarity TEXT NOT NULL DEFAULT '',
image_uri TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_printings_oid ON printings(oracle_id);
-- Secondary trigram FTS index over names (typo-tolerant fallback; the primary
-- interactive search is the in-memory nucleo matcher).
CREATE VIRTUAL TABLE IF NOT EXISTS oracle_names_fts
USING fts5(name, oracle_id UNINDEXED, tokenize = 'trigram');

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MTG Library Catalog</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1971
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "mtgc",
"version": "0.1.0",
"description": "Personal Magic: The Gathering library catalog",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

80
scripts/devel-setup.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
#
# devel-setup.sh — one-time developer environment setup for mtgc.
#
# Installs the Tauri v2 Linux system libraries and the Tauri CLI, and verifies
# the toolchain (Rust, Node, pkg-config libs) needed to build the desktop shell.
#
# Safe to re-run: package installs are idempotent and the CLI install is skipped
# if already present.
#
# Usage:
# ./scripts/devel-setup.sh
#
set -euo pipefail
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; }
err() { printf '\033[1;31m[err]\033[0m %s\n' "$*" >&2; }
# --- 1. Prerequisites already expected on the machine ----------------------
if ! command -v rustc >/dev/null 2>&1; then
err "Rust not found. Install from https://rustup.rs and re-run."
exit 1
fi
info "Rust: $(rustc --version)"
if ! command -v node >/dev/null 2>&1; then
warn "Node.js not found — needed for the Vite/React frontend."
warn "Install Node 18+ (e.g. 'sudo dnf install nodejs') before running the app."
else
info "Node: $(node --version)"
fi
# --- 2. Tauri v2 system libraries (Fedora / dnf) ---------------------------
FEDORA_PKGS=(
webkit2gtk4.1-devel
gtk3-devel
libappindicator-gtk3-devel
librsvg2-devel
file
openssl-devel
)
if command -v dnf >/dev/null 2>&1; then
info "Installing Tauri system libraries via dnf (sudo required)…"
sudo dnf install -y "${FEDORA_PKGS[@]}"
else
warn "dnf not found — this script targets Fedora."
warn "Install the equivalents of: ${FEDORA_PKGS[*]}"
warn "See https://tauri.app/start/prerequisites/ for your distro."
fi
# --- 3. Verify the libraries pkg-config will look for ----------------------
check_pkg() {
if pkg-config --exists "$1" 2>/dev/null; then
info "found $1"
else
warn "missing $1 (Tauri build may fail)"
fi
}
if command -v pkg-config >/dev/null 2>&1; then
check_pkg webkit2gtk-4.1
check_pkg gtk+-3.0
else
warn "pkg-config not found; cannot verify libraries."
fi
# --- 4. Tauri v2 CLI -------------------------------------------------------
if cargo tauri --version >/dev/null 2>&1; then
info "Tauri CLI: $(cargo tauri --version 2>&1 | head -1)"
else
info "Installing Tauri v2 CLI (cargo install tauri-cli --version '^2')…"
cargo install tauri-cli --version '^2'
fi
info "Setup complete. Next: scaffold + run the desktop shell with 'cargo tauri dev'."

21
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "mtgc-app"
version = "0.1.0"
edition = "2021"
description = "MTG Library Catalog desktop app"
[lib]
# The Tauri app logic lives in a lib so the same entry point can serve desktop
# and (later) mobile targets.
name = "mtgc_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
mtgc-core = { path = "../crates/mtgc-core" }

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for the main window",
"windows": ["main"],
"permissions": ["core:default", "dialog:default"]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

163
src-tauri/src/commands.rs Normal file
View File

@@ -0,0 +1,163 @@
//! Tauri commands: the typed IPC surface bridging the web UI to `mtgc-core`.
//!
//! JS passes camelCase argument keys; Tauri maps them to these snake_case params.
use std::path::PathBuf;
use std::sync::Mutex;
use mtgc_core::{CardRef, Deck, DeckEntry, LibraryCard, LibraryFilter};
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use crate::state::AppState;
type Db<'a> = State<'a, Mutex<AppState>>;
type Res<T> = Result<T, String>;
/// High-level status for the top bar / first-run gating.
#[derive(Serialize)]
pub struct Status {
pub library_path: String,
pub reference_path: String,
pub reference_ready: bool,
pub card_count: usize,
}
fn status_of(s: &AppState) -> Status {
Status {
library_path: s.library_path.display().to_string(),
reference_path: s.reference_path.display().to_string(),
reference_ready: s.catalog.is_some(),
card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0),
}
}
#[tauri::command]
pub fn get_status(db: Db) -> Res<Status> {
Ok(status_of(&db.lock().unwrap()))
}
#[tauri::command]
pub fn set_library_path(db: Db, path: String) -> Res<Status> {
let mut s = db.lock().unwrap();
s.library_path = PathBuf::from(path);
s.reopen()?;
Ok(status_of(&s))
}
/// Download Scryfall bulk data and (re)build reference.db, then reopen.
/// Runs on a blocking thread so the UI stays responsive and progress events flow.
#[tauri::command]
pub async fn update_card_data(app: AppHandle, db: Db<'_>) -> Res<Status> {
let reference_path = db.lock().unwrap().reference_path.clone();
let app_for_progress = app.clone();
tauri::async_runtime::spawn_blocking(move || {
mtgc_core::ingest::build_reference_db(&reference_path, |msg| {
let _ = app_for_progress.emit("ingest-progress", msg.to_string());
})
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let mut s = db.lock().unwrap();
s.reopen()?;
Ok(status_of(&s))
}
#[tauri::command]
pub fn search_cards(db: Db, query: String, limit: usize) -> Res<Vec<CardRef>> {
let s = db.lock().unwrap();
Ok(s.index()?.search(&query, limit))
}
#[tauri::command]
pub fn add_owned(db: Db, oracle_id: String, delta: i64) -> Res<i64> {
let s = db.lock().unwrap();
s.catalog()?.add_owned(&oracle_id, delta).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_owned(db: Db, oracle_id: String, quantity: i64) -> Res<i64> {
let s = db.lock().unwrap();
s.catalog()?
.set_owned(&oracle_id, quantity)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_location(db: Db, oracle_id: String, location: String) -> Res<()> {
let s = db.lock().unwrap();
s.catalog()?
.set_location(&oracle_id, &location)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn deck_contents(db: Db, deck_id: i64) -> Res<Vec<DeckEntry>> {
let s = db.lock().unwrap();
s.catalog()?.deck_contents(deck_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn search_library(db: Db, filter: LibraryFilter) -> Res<Vec<LibraryCard>> {
let s = db.lock().unwrap();
s.catalog()?.search_library(&filter).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_decks(db: Db) -> Res<Vec<Deck>> {
let s = db.lock().unwrap();
s.catalog()?.list_decks().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn create_deck(db: Db, name: String, commander_oracle_id: Option<String>) -> Res<Deck> {
let s = db.lock().unwrap();
s.catalog()?
.create_deck(&name, commander_oracle_id.as_deref())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_deck(db: Db, deck_id: i64) -> Res<()> {
let s = db.lock().unwrap();
s.catalog()?.delete_deck(deck_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_deck_format(db: Db, deck_id: i64, format: String) -> Res<()> {
let s = db.lock().unwrap();
s.catalog()?
.set_deck_format(deck_id, &format)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_deck_commander(db: Db, deck_id: i64, oracle_id: Option<String>) -> Res<()> {
let s = db.lock().unwrap();
s.catalog()?
.set_deck_commander(deck_id, oracle_id.as_deref())
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_deck_card(
db: Db,
deck_id: i64,
oracle_id: String,
quantity: i64,
is_commander: bool,
) -> Res<()> {
let s = db.lock().unwrap();
s.catalog()?
.set_deck_card(deck_id, &oracle_id, quantity, is_commander)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn card_decks(db: Db, oracle_id: String) -> Res<Vec<Deck>> {
let s = db.lock().unwrap();
s.catalog()?.card_decks(&oracle_id).map_err(|e| e.to_string())
}

43
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,43 @@
mod commands;
mod state;
use std::sync::Mutex;
use tauri::Manager;
use state::AppState;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let data_dir = app
.path()
.app_data_dir()
.expect("resolve app data dir");
std::fs::create_dir_all(&data_dir).ok();
app.manage(Mutex::new(AppState::new(data_dir)));
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::get_status,
commands::set_library_path,
commands::update_card_data,
commands::search_cards,
commands::add_owned,
commands::set_owned,
commands::set_location,
commands::search_library,
commands::list_decks,
commands::create_deck,
commands::delete_deck,
commands::set_deck_format,
commands::set_deck_commander,
commands::set_deck_card,
commands::deck_contents,
commands::card_decks,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevent an extra console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
mtgc_app_lib::run()
}

58
src-tauri/src/state.rs Normal file
View File

@@ -0,0 +1,58 @@
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the db paths.
use std::path::PathBuf;
use mtgc_core::{Catalog, NameIndex};
/// Managed application state, guarded by a `Mutex` in Tauri's state store.
pub struct AppState {
pub library_path: PathBuf,
pub reference_path: PathBuf,
pub catalog: Option<Catalog>,
pub index: Option<NameIndex>,
}
impl AppState {
/// Initialize with default paths under the app data dir and open the catalog
/// if a reference db already exists.
pub fn new(data_dir: PathBuf) -> Self {
let mut s = Self {
library_path: data_dir.join("library.db"),
reference_path: data_dir.join("reference.db"),
catalog: None,
index: None,
};
// Best-effort open; failures just leave the catalog unloaded.
let _ = s.reopen();
s
}
/// (Re)open the catalog + rebuild the name index. Only opens when a reference
/// db exists — attaching a non-existent file would create an empty (tableless)
/// db and make every query fail.
pub fn reopen(&mut self) -> Result<(), String> {
if self.reference_path.exists() {
let catalog = Catalog::open(&self.library_path, &self.reference_path)
.map_err(|e| e.to_string())?;
let index = catalog.build_name_index().map_err(|e| e.to_string())?;
self.index = Some(index);
self.catalog = Some(catalog);
} else {
self.catalog = None;
self.index = None;
}
Ok(())
}
pub fn catalog(&self) -> Result<&Catalog, String> {
self.catalog
.as_ref()
.ok_or_else(|| "Card data not loaded yet — click 'Download card data'.".to_string())
}
pub fn index(&self) -> Result<&NameIndex, String> {
self.index
.as_ref()
.ok_or_else(|| "Card data not loaded yet — click 'Download card data'.".to_string())
}
}

37
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "MTG Library",
"version": "0.1.0",
"identifier": "com.garrettmills.mtgc",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "MTG Library",
"width": 1200,
"height": 800,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

170
src/App.tsx Normal file
View File

@@ -0,0 +1,170 @@
import { useEffect, useState, useCallback, ReactNode } from "react";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import { api, Status } from "./api";
import { HoverPreviewProvider, usePersistedState } from "./components";
import { CardEntry } from "./views/CardEntry";
import { LibraryBrowser } from "./views/LibraryBrowser";
import { DeckView } from "./views/DeckView";
type Tab = "add" | "library" | "decks";
const svg = (children: ReactNode) => (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
{ id: "add", label: "Add Cards", icon: svg(<path d="M12 5v14M5 12h14" />) },
{
id: "library",
label: "Library",
icon: svg(
<>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</>,
),
},
{
id: "decks",
label: "Decks",
icon: svg(
<>
<path d="M12 2.5l8.5 4.5-8.5 4.5L3.5 7z" />
<path d="M3.5 12l8.5 4.5 8.5-4.5" />
<path d="M3.5 16.5l8.5 4.5 8.5-4.5" />
</>,
),
},
];
export default function App() {
const [status, setStatus] = useState<Status | null>(null);
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
const [progress, setProgress] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const refreshStatus = useCallback(async () => {
setStatus(await api.getStatus());
}, []);
useEffect(() => {
refreshStatus();
const un = listen<string>("ingest-progress", (e) => setProgress(e.payload));
return () => {
un.then((f) => f());
};
}, [refreshStatus]);
async function updateCardData() {
setBusy(true);
setProgress("Starting…");
try {
const s = await api.updateCardData();
setStatus(s);
setProgress(`Loaded ${s.card_count.toLocaleString()} cards.`);
} catch (err) {
setProgress(`Failed: ${String(err)}`);
} finally {
setBusy(false);
}
}
async function chooseLibrary() {
const path = await open({
title: "Choose or create library.db (put it in a synced folder)",
filters: [{ name: "SQLite", extensions: ["db", "sqlite"] }],
});
if (typeof path === "string") {
setStatus(await api.setLibraryPath(path));
}
}
const ready = status?.reference_ready ?? false;
return (
<HoverPreviewProvider>
<div className="app">
<nav className="nav-rail">
<div className="brand">
<span className="brand-mark">M</span>
<span className="brand-text">MTG Library</span>
</div>
<div className="nav-items">
{NAV.map((n) => (
<button
key={n.id}
className={`nav-item ${tab === n.id ? "active" : ""}`}
onClick={() => setTab(n.id)}
>
<span className="nav-icon">{n.icon}</span>
{n.label}
</button>
))}
</div>
<div className="nav-foot">
<span className={`status-dot ${ready ? "on" : "off"}`} />
{ready ? `${status?.card_count.toLocaleString()} cards` : "No card data"}
</div>
</nav>
<div className="main">
<header className="topbar">
{status && (
<span className="path" title={status.library_path}>
{status.library_path}
</span>
)}
<span className="spacer" />
{progress && <span className="progress">{progress}</span>}
<button onClick={chooseLibrary} disabled={busy}>
Choose library…
</button>
<button onClick={updateCardData} disabled={busy} className="primary">
{ready ? "Update card data" : "Download card data"}
</button>
</header>
<div className="content">
{!ready ? (
<div className="empty-state">
<div className="empty-mark">✦</div>
<h2>Welcome to your MTG library</h2>
<p className="hint">
No card database yet. Click <strong>Download card data</strong> to fetch
the latest cards from Scryfall (a few hundred MB, about a minute). The
reference data stays on this device — only your library file is meant to
be synced.
</p>
<button className="primary big" onClick={updateCardData} disabled={busy}>
{busy ? "Downloading…" : "Download card data"}
</button>
</div>
) : tab === "add" ? (
<CardEntry />
) : tab === "library" ? (
<LibraryBrowser />
) : (
<DeckView />
)}
</div>
</div>
</div>
</HoverPreviewProvider>
);
}

109
src/api.ts Normal file
View File

@@ -0,0 +1,109 @@
// Typed wrappers over the Tauri command bridge into mtgc-core.
import { invoke } from "@tauri-apps/api/core";
// --- Types (mirror the serde-serialized core structs) ---------------------
export interface CardRef {
oracle_id: string;
name: string;
mana_value: number;
colors: number; // WUBRG bitmask
color_identity: number; // WUBRG bitmask
type_line: string;
image_uri: string; // Scryfall "normal" image URL, or "" if unavailable
}
export interface LibraryCard extends CardRef {
quantity_owned: number;
decks: string[];
quantity_assigned: number;
location: string;
}
export interface DeckEntry extends CardRef {
quantity: number;
is_commander: boolean;
quantity_owned: number;
}
export interface Deck {
id: number;
name: string;
format: string;
commander_oracle_id: string | null;
notes: string;
}
export interface LibraryFilter {
text?: string | null;
colors_exact?: number | null;
colors_include?: number | null;
commander_identity?: number | null;
cmc_min?: number | null;
cmc_max?: number | null;
types?: string[];
subtypes?: string[];
keywords?: string[];
in_deck?: number | null;
}
export interface Status {
library_path: string;
reference_path: string;
reference_ready: boolean;
card_count: number;
}
// --- WUBRG bitmask helpers ------------------------------------------------
export const COLORS: { bit: number; code: string; label: string }[] = [
{ bit: 1, code: "W", label: "White" },
{ bit: 2, code: "U", label: "Blue" },
{ bit: 4, code: "B", label: "Black" },
{ bit: 8, code: "R", label: "Red" },
{ bit: 16, code: "G", label: "Green" },
];
export function maskToLetters(mask: number): string {
return COLORS.filter((c) => mask & c.bit)
.map((c) => c.code)
.join("");
}
// --- Commands -------------------------------------------------------------
export const api = {
getStatus: () => invoke<Status>("get_status"),
setLibraryPath: (path: string) => invoke<Status>("set_library_path", { path }),
updateCardData: () => invoke<Status>("update_card_data"),
searchCards: (query: string, limit = 20) =>
invoke<CardRef[]>("search_cards", { query, limit }),
addOwned: (oracleId: string, delta: number) =>
invoke<number>("add_owned", { oracleId, delta }),
setOwned: (oracleId: string, quantity: number) =>
invoke<number>("set_owned", { oracleId, quantity }),
setLocation: (oracleId: string, location: string) =>
invoke<void>("set_location", { oracleId, location }),
searchLibrary: (filter: LibraryFilter) =>
invoke<LibraryCard[]>("search_library", { filter }),
listDecks: () => invoke<Deck[]>("list_decks"),
createDeck: (name: string, commanderOracleId: string | null) =>
invoke<Deck>("create_deck", { name, commanderOracleId }),
deleteDeck: (deckId: number) => invoke<void>("delete_deck", { deckId }),
setDeckFormat: (deckId: number, format: string) =>
invoke<void>("set_deck_format", { deckId, format }),
setDeckCommander: (deckId: number, oracleId: string | null) =>
invoke<void>("set_deck_commander", { deckId, oracleId }),
setDeckCard: (
deckId: number,
oracleId: string,
quantity: number,
isCommander = false,
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander }),
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
};

455
src/components.tsx Normal file
View File

@@ -0,0 +1,455 @@
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { api, CardRef, COLORS, maskToLetters } from "./api";
/**
* Inline editor for a card's free-text storage location (e.g. "Box 1").
* Saves on blur; used for owned cards that aren't in any deck.
*/
export function LocationField({
oracleId,
location,
onSaved,
}: {
oracleId: string;
location: string;
onSaved?: () => void;
}) {
const [val, setVal] = useState(location);
useEffect(() => setVal(location), [location]);
const save = () => {
if (val !== location) api.setLocation(oracleId, val).then(() => onSaved?.());
};
return (
<input
className="location-input"
placeholder="Location…"
value={val}
onChange={(e) => setVal(e.target.value)}
onBlur={save}
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
/>
);
}
/** Render a color-identity/colors bitmask as WUBRG pips (colorless = a "C" pip). */
export function ColorPips({ mask }: { mask: number }) {
const letters = maskToLetters(mask);
if (letters.length === 0) {
return (
<span className="pips">
<span className="pip C">C</span>
</span>
);
}
return (
<span className="pips">
{letters.split("").map((c) => (
<span key={c} className={`pip ${c}`}>
{c}
</span>
))}
</span>
);
}
/** Small card-image thumbnail. Hovering it shows the full-size preview. */
export function CardThumb({ card }: { card: CardRef }) {
const { handlers } = useHoverPreview();
if (!card.image_uri) {
return (
<span className="thumb-empty" title="No image">
🂠
</span>
);
}
return (
<img
className="card-thumb"
src={card.image_uri}
alt={card.name}
loading="lazy"
{...handlers(card)}
/>
);
}
/**
* A search input plus a results dropdown with keyboard navigation:
* ↑/↓ move the highlight, Enter picks, mouse hover/click also work.
* `value`/`onChange` are controlled by the caller (which runs the search).
*/
export function CardSearchBox({
placeholder,
value,
onChange,
results,
onPick,
autoFocus,
big,
}: {
placeholder: string;
value: string;
onChange: (v: string) => void;
results: CardRef[];
onPick: (card: CardRef) => void;
autoFocus?: boolean;
/** Larger, more prominent input (used for the primary Add Cards field). */
big?: boolean;
}) {
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => setSel(0), [results]);
// Keep focus in the box after a pick so cards can be added in a rapid streak.
function pick(card: CardRef) {
onPick(card);
inputRef.current?.focus();
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSel((s) => Math.min(s + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSel((s) => Math.max(s - 1, 0));
} else if (e.key === "Enter" && results[sel]) {
e.preventDefault();
pick(results[sel]);
}
}
return (
<>
<input
ref={inputRef}
autoFocus={autoFocus}
style={{
width: "100%",
marginBottom: 8,
...(big ? { fontSize: 16, padding: "10px 12px" } : {}),
}}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/>
{results.length > 0 && (
<ul className="result-list" style={{ marginBottom: 14 }}>
{results.map((c, i) => (
<li
key={c.oracle_id}
style={i === sel ? { background: "var(--panel-2)" } : undefined}
onMouseEnter={() => setSel(i)}
onClick={() => pick(c)}
>
<ColorPips mask={c.color_identity} />
<span className="card-name">{c.name}</span>
<span className="card-type">{c.type_line}</span>
</li>
))}
</ul>
)}
</>
);
}
// --- Hover preview --------------------------------------------------------
interface HoverApi {
enter: (card: CardRef, e: React.MouseEvent) => void;
move: (e: React.MouseEvent) => void;
leave: () => void;
}
const HoverCtx = createContext<HoverApi | null>(null);
/**
* Provides a single floating card-image preview that appears while hovering a
* card and tracks the cursor. Wrap the app once; attach `handlers(card)` from
* [`useHoverPreview`] to any element that should trigger it.
*/
export function HoverPreviewProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<{ card: CardRef; x: number; y: number } | null>(null);
const enter = useCallback((card: CardRef, e: React.MouseEvent) => {
if (card.image_uri) setState({ card, x: e.clientX, y: e.clientY });
}, []);
const move = useCallback((e: React.MouseEvent) => {
setState((s) => (s ? { ...s, x: e.clientX, y: e.clientY } : s));
}, []);
const leave = useCallback(() => setState(null), []);
return (
<HoverCtx.Provider value={{ enter, move, leave }}>
{children}
{state && <FloatingCard card={state.card} x={state.x} y={state.y} />}
</HoverCtx.Provider>
);
}
/** Returns a `handlers(card)` factory to spread onto a row/cell for hover preview. */
export function useHoverPreview() {
const api = useContext(HoverCtx);
return {
handlers: (card: CardRef) =>
api
? {
onMouseEnter: (e: React.MouseEvent) => api.enter(card, e),
onMouseMove: (e: React.MouseEvent) => api.move(e),
onMouseLeave: () => api.leave(),
}
: {},
};
}
const PREVIEW_W = 320;
const PREVIEW_H = 446; // ~ card aspect for the normal image
const GAP = 24;
const PAD = 12;
function FloatingCard({ card, x, y }: { card: CardRef; x: number; y: number }) {
// Prefer the right of the cursor; flip left if it would overflow.
let left = x + GAP;
if (left + PREVIEW_W + PAD > window.innerWidth) left = x - PREVIEW_W - GAP;
const top = Math.max(PAD, Math.min(y - PREVIEW_H / 2, window.innerHeight - PREVIEW_H - PAD));
return (
<img
className="hover-card"
src={card.image_uri}
alt={card.name}
style={{ left, top, width: PREVIEW_W }}
/>
);
}
// --- Grouping -------------------------------------------------------------
export type GroupMode = "none" | "type" | "subtype" | "cmc" | "colors";
export const GROUP_OPTIONS: { value: GroupMode; label: string }[] = [
{ value: "none", label: "No grouping" },
{ value: "type", label: "Card type" },
{ value: "subtype", label: "Subtype" },
{ value: "cmc", label: "Mana value" },
{ value: "colors", label: "Color identity" },
];
// Priority for choosing a card's primary type from its (multi-type) type line.
const TYPE_PRIORITY = [
"Creature",
"Planeswalker",
"Battle",
"Land",
"Artifact",
"Enchantment",
"Instant",
"Sorcery",
];
function primaryType(typeLine: string): string {
const left = typeLine.split("—")[0];
for (const t of TYPE_PRIORITY) if (left.includes(t)) return t;
const words = left.trim().split(/\s+/);
return words[words.length - 1] || "Other";
}
function firstSubtype(typeLine: string): string {
const parts = typeLine.split("—");
if (parts.length < 2) return "No subtype";
return parts[1].trim().split(/\s+/)[0] || "No subtype";
}
function colorLabel(mask: number): string {
const letters = maskToLetters(mask);
return letters === "" ? "Colorless" : letters;
}
/** Group cards by the chosen attribute, returning ordered groups. */
export function groupCards<T extends CardRef>(
cards: T[],
mode: GroupMode,
): { label: string; cards: T[] }[] {
if (mode === "none") return [{ label: "", cards }];
const keyOf = (c: T) =>
mode === "type"
? primaryType(c.type_line)
: mode === "subtype"
? firstSubtype(c.type_line)
: mode === "cmc"
? `Mana value ${c.mana_value}`
: colorLabel(c.color_identity);
const map = new Map<string, T[]>();
for (const c of cards) {
const k = keyOf(c);
const arr = map.get(k);
if (arr) arr.push(c);
else map.set(k, [c]);
}
const groups = [...map.entries()].map(([label, cs]) => ({ label, cards: cs }));
if (mode === "type") {
const rank = (l: string) => {
const i = TYPE_PRIORITY.indexOf(l);
return i === -1 ? TYPE_PRIORITY.length : i;
};
groups.sort((a, b) => rank(a.label) - rank(b.label) || a.label.localeCompare(b.label));
} else if (mode === "cmc") {
groups.sort((a, b) => a.cards[0].mana_value - b.cards[0].mana_value);
} else {
groups.sort((a, b) => a.label.localeCompare(b.label));
}
return groups;
}
/** Dropdown for choosing a [`GroupMode`]. */
export function GroupBySelect({
value,
onChange,
}: {
value: GroupMode;
onChange: (m: GroupMode) => void;
}) {
return (
<label className="group-by">
<span className="muted">Group by</span>
<select value={value} onChange={(e) => onChange(e.target.value as GroupMode)}>
{GROUP_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}
// --- Persistence ----------------------------------------------------------
/** useState that mirrors its value to localStorage (JSON), so it survives
* remounts and app restarts. */
export function usePersistedState<T>(key: string, initial: T): [T, (v: T) => void] {
const [val, setVal] = useState<T>(() => {
const raw = localStorage.getItem(key);
if (raw === null) return initial;
try {
return JSON.parse(raw) as T;
} catch {
return initial;
}
});
const set = useCallback(
(v: T) => {
localStorage.setItem(key, JSON.stringify(v));
setVal(v);
},
[key],
);
return [val, set];
}
// --- View mode (list vs. image tiles) ------------------------------------
export type ViewMode = "list" | "tiles";
/** View-mode state persisted to localStorage so it survives tab switches. */
export function useViewMode(key: string): [ViewMode, (m: ViewMode) => void] {
const [mode, setMode] = useState<ViewMode>(
() => (localStorage.getItem(key) as ViewMode) || "list",
);
const set = useCallback(
(m: ViewMode) => {
localStorage.setItem(key, m);
setMode(m);
},
[key],
);
return [mode, set];
}
/** Segmented control switching between the list and image-tile layouts. */
export function ViewToggle({
mode,
onChange,
}: {
mode: ViewMode;
onChange: (m: ViewMode) => void;
}) {
return (
<span className="view-toggle">
<button
className={mode === "list" ? "active" : ""}
onClick={() => onChange("list")}
title="List view"
>
☰ List
</button>
<button
className={mode === "tiles" ? "active" : ""}
onClick={() => onChange("tiles")}
title="Image tiles"
>
▦ Tiles
</button>
</span>
);
}
/** A single card rendered as a full image tile, with optional corner + footer. */
export function CardTile({
card,
corner,
footer,
}: {
card: CardRef;
corner?: ReactNode;
footer?: ReactNode;
}) {
return (
<div className="tile">
<div className="tile-img-wrap">
{card.image_uri ? (
<img className="tile-img" src={card.image_uri} alt={card.name} loading="lazy" />
) : (
<div className="tile-noimg">{card.name}</div>
)}
{corner && <div className="tile-corner">{corner}</div>}
</div>
{footer && <div className="tile-footer">{footer}</div>}
</div>
);
}
/** Clickable WUBRG toggles that edit a bitmask value. */
export function ColorToggles({
value,
onChange,
}: {
value: number;
onChange: (mask: number) => void;
}) {
return (
<span className="color-toggles">
{COLORS.map((c) => (
<span
key={c.code}
title={c.label}
className={`pip ${c.code} ${value & c.bit ? "on" : ""}`}
onClick={() => onChange(value ^ c.bit)}
>
{c.code}
</span>
))}
</span>
);
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

715
src/styles.css Normal file
View File

@@ -0,0 +1,715 @@
/* ============================================================
mtgc — design system
Terracotta accent (#C8553D) on a layered, Discord-inspired dark UI.
============================================================ */
:root {
/* Layered surfaces (darkest → lightest) */
--bg-rail: #16171a;
--bg-app: #1a1b1f;
--bg-topbar: #1e1f24;
--bg-content: #212228;
--bg-panel: #282a31;
--bg-input: #2f313a;
--bg-hover: #363841;
--bg-elev: #3a3c46;
--border: #34363e;
--border-soft: rgba(255, 255, 255, 0.055);
--text: #dbdee4;
--text-strong: #f5f6f8;
--muted: #9a9ea8;
--faint: #71757f;
/* Terracotta accent */
--accent: #c8553d;
--accent-hover: #d86e56;
--accent-active: #a8412d;
--accent-subtle: rgba(200, 85, 61, 0.15);
--accent-glow: rgba(200, 85, 61, 0.35);
/* Complementary / status */
--info: #46a6bd; /* teal complement */
--gold: #e0b24a;
--success: #4bb073;
--warn: #e0a34a;
--danger: #d8503f;
--radius: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 14px 44px rgba(0, 0, 0, 0.55);
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
background: var(--bg-app);
color: var(--text);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--accent-subtle);
}
/* Thin, unobtrusive scrollbars */
*::-webkit-scrollbar {
width: 11px;
height: 11px;
}
*::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.35);
border-radius: 8px;
border: 3px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
background-clip: padding-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
/* -------- Controls -------- */
button {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 13px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
font-family: inherit;
transition: background 0.12s, border-color 0.12s, transform 0.05s, filter 0.12s;
}
button:hover {
background: var(--bg-hover);
border-color: var(--bg-elev);
}
button:active {
transform: translateY(1px);
}
button:disabled {
opacity: 0.5;
cursor: default;
transform: none;
}
button.primary {
background: linear-gradient(180deg, var(--accent-hover), var(--accent));
border: none;
color: #fff;
box-shadow: var(--shadow-sm);
}
button.primary:hover {
filter: brightness(1.07);
background: linear-gradient(180deg, var(--accent-hover), var(--accent));
}
button.primary:active {
background: var(--accent-active);
}
button.big {
padding: 11px 22px;
font-size: 15px;
border-radius: 10px;
}
input,
select {
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 11px;
font-size: 13px;
font-family: inherit;
transition: border-color 0.12s, box-shadow 0.12s;
}
input::placeholder {
color: var(--faint);
}
input:focus,
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
/* ============================================================
App shell: nav rail + main
============================================================ */
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
.nav-rail {
width: 220px;
flex-shrink: 0;
background: var(--bg-rail);
border-right: 1px solid var(--border-soft);
display: flex;
flex-direction: column;
padding: 14px 10px 10px;
}
.brand {
display: flex;
align-items: center;
gap: 11px;
padding: 4px 8px 16px;
}
.brand-mark {
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(150deg, var(--accent-hover), var(--accent-active));
color: #fff;
font-weight: 800;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
}
.brand-text {
font-weight: 700;
font-size: 15px;
color: var(--text-strong);
letter-spacing: 0.2px;
}
.nav-items {
display: flex;
flex-direction: column;
gap: 3px;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
padding: 9px 12px;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--muted);
font-size: 14px;
font-weight: 550;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.nav-item.active {
background: var(--accent-subtle);
color: var(--text-strong);
}
.nav-item.active::before {
content: "";
position: absolute;
left: -10px;
top: 9px;
bottom: 9px;
width: 4px;
border-radius: 0 4px 4px 0;
background: var(--accent);
}
.nav-icon {
display: inline-flex;
color: inherit;
}
.nav-foot {
margin-top: auto;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 10px 6px;
font-size: 12px;
color: var(--muted);
border-top: 1px solid var(--border-soft);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.on {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.status-dot.off {
background: var(--faint);
}
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-content);
}
.topbar {
height: 52px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
background: var(--bg-topbar);
border-bottom: 1px solid var(--border-soft);
}
.path {
font-size: 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: var(--faint);
background: rgba(0, 0, 0, 0.25);
padding: 4px 10px;
border-radius: 6px;
max-width: 460px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress {
color: var(--accent-hover);
font-size: 12px;
font-weight: 500;
}
.spacer {
flex: 1;
}
.content {
flex: 1;
overflow: auto;
padding: 22px 26px;
}
/* Empty / welcome state */
.empty-state {
max-width: 480px;
margin: 9vh auto 0;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.empty-mark {
width: 72px;
height: 72px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 34px;
color: #fff;
background: linear-gradient(150deg, var(--accent-hover), var(--accent-active));
box-shadow: 0 10px 30px var(--accent-glow);
}
.empty-state h2 {
margin: 4px 0 0;
}
/* ============================================================
Typography helpers
============================================================ */
h2 {
margin: 2px 0 14px;
font-size: 19px;
font-weight: 700;
color: var(--text-strong);
letter-spacing: -0.2px;
}
h3 {
margin: 18px 0 10px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--faint);
border-bottom: 1px solid var(--border-soft);
padding-bottom: 8px;
}
h4 {
margin: 14px 0 7px;
font-size: 13px;
font-weight: 600;
color: var(--accent-hover);
}
.muted {
color: var(--muted);
}
.hint {
color: var(--muted);
font-size: 13px;
line-height: 1.65;
}
.row {
display: flex;
gap: 8px;
align-items: center;
}
.badge {
display: inline-flex;
align-items: center;
background: var(--accent-subtle);
color: var(--accent-hover);
border-radius: 999px;
padding: 1px 9px;
font-size: 11px;
font-weight: 700;
}
/* ============================================================
Lists (search results, deck list)
============================================================ */
.result-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--bg-panel);
}
.result-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
border-bottom: 1px solid var(--border-soft);
cursor: pointer;
transition: background 0.1s;
}
.result-list li:last-child {
border-bottom: none;
}
.result-list li:hover {
background: var(--bg-hover);
}
.card-name {
font-weight: 600;
color: var(--text-strong);
}
.card-type {
color: var(--muted);
font-size: 12px;
}
/* ============================================================
Color pips (WUBRG)
============================================================ */
.pips {
display: inline-flex;
gap: 3px;
}
.pip {
width: 18px;
height: 18px;
border-radius: 50%;
font-size: 10px;
font-weight: 800;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.25);
}
.pip.W { background: #f4efdd; color: #4a3f2a; }
.pip.U { background: #3a8fd0; color: #fff; }
.pip.B { background: #38343d; color: #cbc3d4; }
.pip.R { background: var(--accent); color: #fff; }
.pip.G { background: #479b63; color: #fff; }
.pip.C { background: #9aa0ab; color: #23242a; }
.color-toggles {
display: flex;
gap: 5px;
}
.color-toggles .pip {
cursor: pointer;
opacity: 0.32;
transition: opacity 0.1s, box-shadow 0.1s;
}
.color-toggles .pip.on {
opacity: 1;
box-shadow: 0 0 0 2px var(--accent-hover);
}
/* ============================================================
Tables
============================================================ */
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--faint);
position: sticky;
top: 0;
background: var(--bg-content);
border-bottom: 1px solid var(--border);
z-index: 1;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-soft);
vertical-align: middle;
}
tbody tr {
transition: background 0.08s;
}
tbody tr:hover {
background: rgba(255, 255, 255, 0.022);
}
.over-assigned {
color: var(--warn);
font-weight: 600;
}
.deck-chip {
display: inline-flex;
align-items: center;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 8px;
font-size: 11px;
color: var(--text);
margin-right: 4px;
}
/* ============================================================
Filters panel
============================================================ */
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 14px;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field > label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--faint);
}
.location-input {
padding: 4px 8px;
font-size: 12px;
max-width: 160px;
}
/* ============================================================
Card images: thumbnails, hover preview, tiles
============================================================ */
.card-thumb {
width: 40px;
height: 56px;
object-fit: cover;
border-radius: 5px;
display: block;
background: var(--bg-elev);
cursor: zoom-in;
box-shadow: var(--shadow-sm);
transition: transform 0.1s, box-shadow 0.1s;
}
.card-thumb:hover {
transform: scale(1.05);
box-shadow: 0 0 0 2px var(--accent-hover), var(--shadow-md);
}
.thumb-empty {
display: inline-flex;
width: 40px;
height: 56px;
align-items: center;
justify-content: center;
color: var(--faint);
border: 1px dashed var(--border);
border-radius: 5px;
font-size: 18px;
}
.hover-card {
position: fixed;
z-index: 200;
border-radius: 14px;
box-shadow: var(--shadow-lg);
border: 1px solid rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 16px;
}
.tile {
display: flex;
flex-direction: column;
gap: 7px;
}
.tile-img-wrap {
position: relative;
}
.tile-img {
width: 100%;
display: block;
border-radius: 12px;
box-shadow: var(--shadow-sm);
transition: transform 0.12s, box-shadow 0.12s;
}
.tile:hover .tile-img {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
}
.tile-noimg {
aspect-ratio: 488 / 680;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-panel);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 14px;
font-weight: 600;
color: var(--muted);
}
.tile-corner {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 6px;
}
.tile-badge {
background: rgba(0, 0, 0, 0.78);
color: #fff;
border-radius: 8px;
padding: 2px 9px;
font-size: 12px;
font-weight: 700;
backdrop-filter: blur(2px);
}
.tile-badge.warn {
background: var(--warn);
color: #241700;
}
.tile-remove {
background: rgba(0, 0, 0, 0.78);
border: none;
color: #fff;
border-radius: 50%;
width: 24px;
height: 24px;
padding: 0;
line-height: 1;
font-size: 13px;
}
.tile-remove:hover {
background: var(--danger);
}
.tile-footer {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
/* ============================================================
Toolbar controls: group-by + view toggle
============================================================ */
.group-by {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: var(--muted);
}
.view-toggle {
display: inline-flex;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.view-toggle button {
border: none;
border-radius: 0;
background: transparent;
font-size: 12px;
padding: 5px 12px;
color: var(--muted);
}
.view-toggle button:hover {
background: rgba(255, 255, 255, 0.04);
}
.view-toggle button.active {
background: var(--accent);
color: #fff;
}
/* ============================================================
Deck metadata (format + commander)
============================================================ */
.deck-meta {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 20px;
background: var(--bg-panel);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 14px 16px;
}
.commander-box {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 280px;
flex: 1;
}

83
src/views/CardEntry.tsx Normal file
View File

@@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import { api, CardRef } from "../api";
import { CardSearchBox } from "../components";
/** Rapid card entry: fuzzy search-as-you-type, ↑/↓ + Enter (or click) adds one copy. */
export function CardEntry() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<CardRef[]>([]);
const [recent, setRecent] = useState<{ name: string; qty: number; location: string }[]>([]);
// Persisted so a "box" location sticks while entering a stack of cards.
const [location, setLocation] = useState(() => localStorage.getItem("add-location") || "");
useEffect(() => {
localStorage.setItem("add-location", location);
}, [location]);
// Debounced fuzzy search.
useEffect(() => {
const q = query.trim();
if (!q) {
setResults([]);
return;
}
const t = setTimeout(async () => {
try {
setResults(await api.searchCards(q, 20));
} catch {
setResults([]);
}
}, 80);
return () => clearTimeout(t);
}, [query]);
async function add(card: CardRef) {
const qty = await api.addOwned(card.oracle_id, 1);
const loc = location.trim();
if (loc) await api.setLocation(card.oracle_id, loc);
setRecent((r) => [{ name: card.name, qty, location: loc }, ...r].slice(0, 12));
setQuery("");
setResults([]);
}
return (
<div>
<h2>Add Cards</h2>
<div className="field" style={{ maxWidth: 260, marginBottom: 12 }}>
<label>Location for added cards (optional)</label>
<input
placeholder="e.g. Box 1 — prefilled onto each card"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
<CardSearchBox
big
autoFocus
placeholder="Type a card name… (typos are fine — ↑/↓ then Enter to add)"
value={query}
onChange={setQuery}
results={results}
onPick={add}
/>
{recent.length > 0 && (
<>
<h2 style={{ marginTop: 24 }}>Just added</h2>
<ul className="result-list">
{recent.map((r, i) => (
<li key={i} style={{ cursor: "default" }}>
<span className="card-name">{r.name}</span>
{r.location && <span className="deck-chip">{r.location}</span>}
<span className="spacer" />
<span className="badge">owned ×{r.qty}</span>
</li>
))}
</ul>
</>
)}
</div>
);
}

377
src/views/DeckView.tsx Normal file
View File

@@ -0,0 +1,377 @@
import { useEffect, useState, useCallback } from "react";
import { api, CardRef, Deck, DeckEntry } from "../api";
import {
CardSearchBox,
CardThumb,
CardTile,
ColorPips,
GroupBySelect,
GroupMode,
ViewToggle,
groupCards,
usePersistedState,
useViewMode,
} from "../components";
const FORMATS = [
"",
"Commander",
"Brawl",
"Standard",
"Pioneer",
"Modern",
"Legacy",
"Vintage",
"Pauper",
"Other",
];
const COMMANDER_FORMATS = ["Commander", "Brawl"];
/**
* Deck editor. A deck's cards are split into two groups — "In your library"
* (owned) and "Not owned" — each with its own search box, and each groupable by
* card attribute. Deck-level format + commander metadata sit at the top.
*/
export function DeckView() {
const [decks, setDecks] = useState<Deck[]>([]);
const [selected, setSelected] = usePersistedState<number | null>("deck-selected", null);
const [newName, setNewName] = useState("");
const [contents, setContents] = useState<DeckEntry[]>([]);
const [view, setView] = useViewMode("deck-view");
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
const [libQuery, setLibQuery] = useState("");
const [libResults, setLibResults] = useState<CardRef[]>([]);
const [anyQuery, setAnyQuery] = useState("");
const [anyResults, setAnyResults] = useState<CardRef[]>([]);
const [cmdQuery, setCmdQuery] = useState("");
const [cmdResults, setCmdResults] = useState<CardRef[]>([]);
const deck = decks.find((d) => d.id === selected) ?? null;
const loadDecks = useCallback(async () => {
const d = await api.listDecks();
setDecks(d);
// Keep the persisted selection if it still exists, else fall back to the first.
if (d.length && (selected === null || !d.some((x) => x.id === selected))) {
setSelected(d[0].id);
}
}, [selected, setSelected]);
useEffect(() => {
loadDecks();
}, [loadDecks]);
const loadContents = useCallback(async () => {
if (selected === null) {
setContents([]);
return;
}
setContents(await api.deckContents(selected));
}, [selected]);
useEffect(() => {
loadContents();
}, [loadContents]);
useEffect(() => {
const q = libQuery.trim();
if (!q) return setLibResults([]);
const t = setTimeout(() => api.searchLibrary({ text: q }).then(setLibResults), 100);
return () => clearTimeout(t);
}, [libQuery]);
useEffect(() => {
const q = anyQuery.trim();
if (!q) return setAnyResults([]);
const t = setTimeout(() => api.searchCards(q, 12).then(setAnyResults), 80);
return () => clearTimeout(t);
}, [anyQuery]);
useEffect(() => {
const q = cmdQuery.trim();
if (!q) return setCmdResults([]);
const t = setTimeout(() => api.searchCards(q, 12).then(setCmdResults), 80);
return () => clearTimeout(t);
}, [cmdQuery]);
async function createDeck() {
if (!newName.trim()) return;
const d = await api.createDeck(newName.trim(), null);
setNewName("");
await loadDecks();
setSelected(d.id);
}
async function addToDeck(card: CardRef) {
if (selected === null) return;
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
const qty = existing ? existing.quantity + 1 : 1;
await api.setDeckCard(selected, card.oracle_id, qty, false);
setLibQuery("");
setLibResults([]);
setAnyQuery("");
setAnyResults([]);
await loadContents();
}
async function setQty(oracleId: string, qty: number) {
if (selected === null) return;
await api.setDeckCard(selected, oracleId, qty, false);
await loadContents();
}
async function removeDeck(id: number) {
await api.deleteDeck(id);
if (selected === id) setSelected(null);
await loadDecks();
}
async function changeFormat(format: string) {
if (selected === null) return;
await api.setDeckFormat(selected, format);
await loadDecks();
}
async function setCommander(card: CardRef) {
if (selected === null) return;
// Ensure the commander is in the deck, then flag it.
if (!contents.find((e) => e.oracle_id === card.oracle_id)) {
await api.setDeckCard(selected, card.oracle_id, 1, true);
}
await api.setDeckCommander(selected, card.oracle_id);
setCmdQuery("");
setCmdResults([]);
await loadContents();
await loadDecks();
}
async function clearCommander() {
if (selected === null) return;
await api.setDeckCommander(selected, null);
await loadContents();
await loadDecks();
}
const owned = contents.filter((e) => e.quantity_owned > 0);
const unowned = contents.filter((e) => e.quantity_owned === 0);
const commanderName =
deck?.commander_oracle_id &&
(contents.find((e) => e.oracle_id === deck.commander_oracle_id)?.name ??
deck.commander_oracle_id);
function deckTiles(entries: DeckEntry[]) {
return (
<div className="tile-grid">
{entries.map((e) => (
<CardTile
key={e.oracle_id}
card={e}
corner={
<button
className="tile-remove"
title="Remove from deck"
onClick={() => setQty(e.oracle_id, 0)}
>
✕
</button>
}
footer={
<span className="tile-badge">
{e.is_commander ? "★ " : ""}in deck ×{e.quantity}
</span>
}
/>
))}
</div>
);
}
function deckTable(entries: DeckEntry[]) {
return (
<table>
<thead>
<tr>
<th></th>
<th>Colors</th>
<th>Name</th>
<th>Type</th>
<th>In deck</th>
<th>Owned</th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.oracle_id}>
<td>
<CardThumb card={e} />
</td>
<td>
<ColorPips mask={e.color_identity} />
</td>
<td className="card-name">
{e.is_commander && <span title="Commander">★ </span>}
{e.name}
</td>
<td className="card-type">{e.type_line}</td>
<td>
<input
type="number"
min={0}
style={{ width: 60 }}
key={e.quantity}
defaultValue={e.quantity}
onBlur={(ev) => setQty(e.oracle_id, Number(ev.target.value))}
/>
</td>
<td>{e.quantity_owned}</td>
<td>
<button onClick={() => setQty(e.oracle_id, 0)}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
function cardsGroup(entries: DeckEntry[]) {
if (entries.length === 0) return <p className="hint">None yet.</p>;
return groupCards(entries, groupBy).map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
{g.label && (
<h4>
{g.label} <span className="badge">{g.cards.length}</span>
</h4>
)}
{view === "tiles" ? deckTiles(g.cards) : deckTable(g.cards)}
</div>
));
}
return (
<div style={{ display: "flex", gap: 20 }}>
<div style={{ width: 240, flexShrink: 0 }}>
<h2>Decks</h2>
<div className="row" style={{ marginBottom: 10 }}>
<input
value={newName}
placeholder="New deck name"
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createDeck()}
/>
<button className="primary" onClick={createDeck}>
+
</button>
</div>
<ul className="result-list">
{decks.map((d) => (
<li
key={d.id}
style={d.id === selected ? { background: "var(--panel-2)" } : undefined}
onClick={() => setSelected(d.id)}
>
<span className="card-name">{d.name}</span>
{d.format && <span className="deck-chip">{d.format}</span>}
<span className="spacer" />
<button
onClick={(e) => {
e.stopPropagation();
removeDeck(d.id);
}}
title="Delete deck"
>
✕
</button>
</li>
))}
{decks.length === 0 && (
<li style={{ cursor: "default" }} className="hint">
No decks yet.
</li>
)}
</ul>
</div>
<div style={{ flex: 1 }}>
{selected === null || !deck ? (
<p className="hint">Select or create a deck.</p>
) : (
<>
<div className="row" style={{ justifyContent: "space-between" }}>
<h2>{deck.name}</h2>
<div className="row" style={{ gap: 16 }}>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
</div>
</div>
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
<div className="deck-meta">
<label className="row" style={{ gap: 6 }}>
<span className="muted">Format</span>
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
{FORMATS.map((f) => (
<option key={f} value={f}>
{f || "—"}
</option>
))}
</select>
</label>
{COMMANDER_FORMATS.includes(deck.format) && (
<div className="commander-box">
<div className="row" style={{ gap: 8 }}>
<span className="muted">Commander:</span>
<strong>{commanderName || "none"}</strong>
{commanderName && (
<button onClick={clearCommander} title="Clear commander">
✕
</button>
)}
</div>
<CardSearchBox
placeholder="Search to set the commander…"
value={cmdQuery}
onChange={setCmdQuery}
results={cmdResults}
onPick={setCommander}
/>
</div>
)}
</div>
<section style={{ marginTop: 18 }}>
<h3>
In your library <span className="badge">{owned.length}</span>
</h3>
<CardSearchBox
placeholder="Search your library to add owned cards…"
value={libQuery}
onChange={setLibQuery}
results={libResults}
onPick={addToDeck}
/>
{cardsGroup(owned)}
</section>
<section style={{ marginTop: 26 }}>
<h3>
Not owned <span className="badge">{unowned.length}</span>
</h3>
<CardSearchBox
placeholder="Search all cards to add ones you don't own…"
value={anyQuery}
onChange={setAnyQuery}
results={anyResults}
onPick={addToDeck}
/>
{cardsGroup(unowned)}
</section>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,235 @@
import { useEffect, useState } from "react";
import { api, Deck, LibraryCard, LibraryFilter } from "../api";
import {
CardThumb,
CardTile,
ColorPips,
ColorToggles,
GroupBySelect,
GroupMode,
LocationField,
ViewToggle,
groupCards,
usePersistedState,
useViewMode,
} from "../components";
/** Browse & filter the owned library by name and card criteria. */
export function LibraryBrowser() {
const [text, setText] = useState("");
const [commander, setCommander] = useState(0);
const [colorsInclude, setColorsInclude] = useState(0);
const [cmcMin, setCmcMin] = useState("");
const [cmcMax, setCmcMax] = useState("");
const [type, setType] = useState("");
const [subtype, setSubtype] = useState("");
const [keyword, setKeyword] = useState("");
const [inDeck, setInDeck] = useState<number | "">("");
const [decks, setDecks] = useState<Deck[]>([]);
const [rows, setRows] = useState<LibraryCard[]>([]);
const [view, setView] = useViewMode("library-view");
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("library-group", "none");
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
api.listDecks().then(setDecks);
}, []);
useEffect(() => {
const filter: LibraryFilter = {
text: text.trim() || null,
commander_identity: commander || null,
colors_include: colorsInclude || null,
cmc_min: cmcMin === "" ? null : Number(cmcMin),
cmc_max: cmcMax === "" ? null : Number(cmcMax),
types: splitCsv(type),
subtypes: splitCsv(subtype),
keywords: splitCsv(keyword),
in_deck: inDeck === "" ? null : Number(inDeck),
};
const t = setTimeout(() => {
api.searchLibrary(filter).then(setRows).catch(() => setRows([]));
}, 120);
return () => clearTimeout(t);
}, [text, commander, colorsInclude, cmcMin, cmcMax, type, subtype, keyword, inDeck, refreshKey]);
const deckOrLocation = (c: LibraryCard) =>
c.decks.length === 0 ? (
<LocationField
oracleId={c.oracle_id}
location={c.location}
onSaved={() => setRefreshKey((k) => k + 1)}
/>
) : (
c.decks.map((d) => (
<span key={d} className="deck-chip">
{d}
</span>
))
);
function libTable(cards: LibraryCard[]) {
return (
<table>
<thead>
<tr>
<th></th>
<th>Colors</th>
<th>Name</th>
<th>Type</th>
<th>MV</th>
<th>Owned</th>
<th>Decks / Location</th>
</tr>
</thead>
<tbody>
{cards.map((c) => (
<tr key={c.oracle_id}>
<td>
<CardThumb card={c} />
</td>
<td>
<ColorPips mask={c.color_identity} />
</td>
<td className="card-name">{c.name}</td>
<td className="card-type">{c.type_line}</td>
<td>{c.mana_value}</td>
<td className={c.quantity_assigned > c.quantity_owned ? "over-assigned" : ""}>
{c.quantity_owned}
{c.quantity_assigned > c.quantity_owned && (
<span title="Assigned to more decks than you own">
{" "}
⚠ {c.quantity_assigned} assigned
</span>
)}
</td>
<td>{deckOrLocation(c)}</td>
</tr>
))}
</tbody>
</table>
);
}
function libTiles(cards: LibraryCard[]) {
return (
<div className="tile-grid">
{cards.map((c) => (
<CardTile
key={c.oracle_id}
card={c}
corner={
<span
className={`tile-badge ${c.quantity_assigned > c.quantity_owned ? "warn" : ""}`}
title="Copies owned"
>
×{c.quantity_owned}
{c.quantity_assigned > c.quantity_owned ? " ⚠" : ""}
</span>
}
footer={deckOrLocation(c)}
/>
))}
</div>
);
}
const groups = groupCards(rows, groupBy);
return (
<div>
<div className="row" style={{ justifyContent: "space-between", marginBottom: 12 }}>
<h2 style={{ margin: 0 }}>
Library <span className="badge">{rows.length}</span>
</h2>
<div className="row" style={{ gap: 16 }}>
<GroupBySelect value={groupBy} onChange={setGroupBy} />
<ViewToggle mode={view} onChange={setView} />
</div>
</div>
<div className="filters">
<div className="field">
<label>Name contains</label>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="e.g. bolt" />
</div>
<div className="field">
<label>Playable in commander (color identity ⊆)</label>
<ColorToggles value={commander} onChange={setCommander} />
</div>
<div className="field">
<label>Includes colors</label>
<ColorToggles value={colorsInclude} onChange={setColorsInclude} />
</div>
<div className="field">
<label>Mana value</label>
<div className="row">
<input
type="number"
style={{ width: 70 }}
placeholder="min"
value={cmcMin}
onChange={(e) => setCmcMin(e.target.value)}
/>
<span className="muted">–</span>
<input
type="number"
style={{ width: 70 }}
placeholder="max"
value={cmcMax}
onChange={(e) => setCmcMax(e.target.value)}
/>
</div>
</div>
<div className="field">
<label>Type(s)</label>
<input value={type} onChange={(e) => setType(e.target.value)} placeholder="Creature, Legendary" />
</div>
<div className="field">
<label>Subtype(s)</label>
<input value={subtype} onChange={(e) => setSubtype(e.target.value)} placeholder="Elf" />
</div>
<div className="field">
<label>Keyword(s)</label>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="Flying" />
</div>
<div className="field">
<label>In deck</label>
<select value={inDeck} onChange={(e) => setInDeck(e.target.value === "" ? "" : Number(e.target.value))}>
<option value="">Any</option>
{decks.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</div>
</div>
{rows.length === 0 ? (
<p className="hint">
No matching cards owned. Add some in the Add Cards tab, or loosen the filters.
</p>
) : (
groups.map((g) => (
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
{g.label && (
<h3>
{g.label} <span className="badge">{g.cards.length}</span>
</h3>
)}
{view === "list" ? libTable(g.cards) : libTiles(g.cards)}
</div>
))
)}
</div>
);
}
function splitCsv(s: string): string[] {
return s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
}

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

16
vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Vite config tuned for Tauri: fixed port, no clearing the terminal Tauri owns.
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
watch: {
// Don't watch the Rust side; Tauri handles that.
ignored: ["**/src-tauri/**"],
},
},
});