Add deck legality checking, deck/library import-export, and a command palette
Threads Scryfall's per-format legalities through the reference db and adds a deck-page indicator (structural rules + banned/restricted lists) that lists violation reasons inline. Decks and selected library cards can now be exported to/imported from plain-text decklists, with best-effort name resolution and a report of anything that didn't match. Also adds a shuffle- and-draw sample hand modal, and an Alt+/ command palette for jumping to a tab, deck, or card without leaving the keyboard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
474
crates/mtgc-core/src/decklist.rs
Normal file
474
crates/mtgc-core/src/decklist.rs
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
//! Plain-text decklist import/export: parsing and formatting for decks
|
||||||
|
//! (with Commander/Deck/Maybeboard sections) and flat library card lists.
|
||||||
|
//!
|
||||||
|
//! Deck export format:
|
||||||
|
//! ```text
|
||||||
|
//! // mtgc deck export
|
||||||
|
//! // Format: Commander
|
||||||
|
//!
|
||||||
|
//! Commander
|
||||||
|
//! 1 Krenko, Mob Boss
|
||||||
|
//!
|
||||||
|
//! Deck
|
||||||
|
//! 1 Sol Ring
|
||||||
|
//! 4 Lightning Bolt
|
||||||
|
//!
|
||||||
|
//! Maybeboard
|
||||||
|
//! 1 Some Card
|
||||||
|
//! ```
|
||||||
|
//! `//` lines are comments (`// Format: X` is recognized specially to
|
||||||
|
//! restore the deck's format on import). Section headers are
|
||||||
|
//! case-insensitive; `Sideboard` is accepted as an input alias for
|
||||||
|
//! `Maybeboard` (compatibility with lists copied from other tools), but
|
||||||
|
//! export always emits `Maybeboard`. Cards before any header, or under
|
||||||
|
//! `Deck`, are mainboard. Quantities accept a bare integer (`4 Foo`) or an
|
||||||
|
//! `Nx`/`N×` prefix (`4x Foo`). Unrecognized lines are silently skipped,
|
||||||
|
//! never a hard error — see [`parse_deck_text`].
|
||||||
|
//!
|
||||||
|
//! Library export format is the same comment/quantity-name line convention
|
||||||
|
//! with no sections — see [`parse_library_text`].
|
||||||
|
|
||||||
|
use rusqlite::{params, OptionalExtension};
|
||||||
|
|
||||||
|
use crate::db::REF_DB;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::model::{Deck, DeckEntry};
|
||||||
|
use crate::Catalog;
|
||||||
|
|
||||||
|
/// One `<quantity> <name>` line from a decklist, prior to name resolution.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct DecklistLine {
|
||||||
|
pub quantity: i64,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The structured result of parsing deck-export text.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct ParsedDeckText {
|
||||||
|
/// From a `// Format: X` comment, if present.
|
||||||
|
pub format: Option<String>,
|
||||||
|
/// Commander section lines. Normally 0 or 1 — see
|
||||||
|
/// [`Catalog::import_deck`] for how extra lines are handled.
|
||||||
|
pub commander: Vec<DecklistLine>,
|
||||||
|
pub mainboard: Vec<DecklistLine>,
|
||||||
|
pub maybeboard: Vec<DecklistLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum Section {
|
||||||
|
Mainboard,
|
||||||
|
Commander,
|
||||||
|
Maybeboard,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse deck-export text into structured lines. Lenient: blank lines are
|
||||||
|
/// skipped, `//` lines are comments, section headers are case-insensitive,
|
||||||
|
/// and lines that don't parse as `<qty> <name>` are silently skipped rather
|
||||||
|
/// than erroring the whole import.
|
||||||
|
pub fn parse_deck_text(text: &str) -> ParsedDeckText {
|
||||||
|
let mut parsed = ParsedDeckText::default();
|
||||||
|
let mut section = Section::Mainboard;
|
||||||
|
|
||||||
|
for raw_line in text.lines() {
|
||||||
|
let line = raw_line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(rest) = line.strip_prefix("//") {
|
||||||
|
let rest = rest.trim();
|
||||||
|
if let Some(fmt) = rest
|
||||||
|
.strip_prefix("Format:")
|
||||||
|
.or_else(|| rest.strip_prefix("format:"))
|
||||||
|
{
|
||||||
|
parsed.format = Some(fmt.trim().to_string());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match line.to_lowercase().as_str() {
|
||||||
|
"commander" => {
|
||||||
|
section = Section::Commander;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
"deck" => {
|
||||||
|
section = Section::Mainboard;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
"maybeboard" | "sideboard" => {
|
||||||
|
section = Section::Maybeboard;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if let Some(card_line) = parse_card_line(line) {
|
||||||
|
match section {
|
||||||
|
Section::Mainboard => parsed.mainboard.push(card_line),
|
||||||
|
Section::Commander => parsed.commander.push(card_line),
|
||||||
|
Section::Maybeboard => parsed.maybeboard.push(card_line),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a single `<qty> <name>` line. Accepts a bare integer quantity
|
||||||
|
/// (`4 Lightning Bolt`) or an `Nx`/`N×` prefix (`4x Lightning Bolt`).
|
||||||
|
/// Returns `None` (skip, don't error) if the line doesn't start with a
|
||||||
|
/// recognizable positive quantity or has no name after it.
|
||||||
|
fn parse_card_line(line: &str) -> Option<DecklistLine> {
|
||||||
|
let (head, rest) = line.split_once(char::is_whitespace)?;
|
||||||
|
let head_lower = head.to_lowercase();
|
||||||
|
let qty_str = head_lower
|
||||||
|
.strip_suffix('x')
|
||||||
|
.or_else(|| head_lower.strip_suffix('×'))
|
||||||
|
.unwrap_or(&head_lower);
|
||||||
|
let quantity: i64 = qty_str.parse().ok()?;
|
||||||
|
if quantity <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = rest.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(DecklistLine {
|
||||||
|
quantity,
|
||||||
|
name: name.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse library-export text into `<qty> <name>` lines (no sections). Same
|
||||||
|
/// leniency rules as [`parse_deck_text`].
|
||||||
|
pub fn parse_library_text(text: &str) -> Vec<DecklistLine> {
|
||||||
|
text.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||||
|
.filter_map(parse_card_line)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a deck (metadata + contents) as export text. Empty sections (no
|
||||||
|
/// commander, no maybeboard entries) are omitted entirely. The
|
||||||
|
/// `// Format: X` comment is emitted only when `deck.format` is non-empty.
|
||||||
|
pub fn format_deck_text(deck: &Deck, contents: &[DeckEntry]) -> String {
|
||||||
|
let mut out = String::from("// mtgc deck export\n");
|
||||||
|
if !deck.format.is_empty() {
|
||||||
|
out.push_str(&format!("// Format: {}\n", deck.format));
|
||||||
|
}
|
||||||
|
|
||||||
|
let commander: Vec<_> = contents.iter().filter(|e| e.is_commander).collect();
|
||||||
|
let maybeboard: Vec<_> = contents.iter().filter(|e| e.is_maybeboard).collect();
|
||||||
|
let mainboard: Vec<_> = contents
|
||||||
|
.iter()
|
||||||
|
.filter(|e| !e.is_commander && !e.is_maybeboard)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !commander.is_empty() {
|
||||||
|
out.push_str("\nCommander\n");
|
||||||
|
for e in &commander {
|
||||||
|
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !mainboard.is_empty() {
|
||||||
|
out.push_str("\nDeck\n");
|
||||||
|
for e in &mainboard {
|
||||||
|
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !maybeboard.is_empty() {
|
||||||
|
out.push_str("\nMaybeboard\n");
|
||||||
|
for e in &maybeboard {
|
||||||
|
out.push_str(&format!("{} {}\n", e.quantity, e.card.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render `(name, quantity)` pairs as library-export text.
|
||||||
|
pub fn format_library_text(entries: &[(String, i64)]) -> String {
|
||||||
|
let mut out = String::from("// mtgc library export\n");
|
||||||
|
for (name, qty) in entries {
|
||||||
|
out.push_str(&format!("{qty} {name}\n"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Catalog {
|
||||||
|
/// Case-insensitive exact-name lookup against the reference db's
|
||||||
|
/// precomputed `name_lower` column. No fuzzy matching — an unresolved
|
||||||
|
/// name is reported back to the caller rather than guessed at, so a
|
||||||
|
/// typo never silently imports the wrong card.
|
||||||
|
pub fn find_oracle_id_by_name(&self, name: &str) -> Result<Option<String>> {
|
||||||
|
Ok(self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
&format!("SELECT oracle_id FROM {REF_DB}.oracle_cards WHERE name_lower = ?1 LIMIT 1"),
|
||||||
|
params![name.trim().to_lowercase()],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a decklist as a brand-new deck named `name`. Always creates a
|
||||||
|
/// new deck — never merges into an existing one. Best-effort: card
|
||||||
|
/// names that don't resolve are skipped (not a hard failure) and
|
||||||
|
/// collected into the returned list, alongside the created `Deck`.
|
||||||
|
///
|
||||||
|
/// Only the first resolved Commander-section line becomes the actual
|
||||||
|
/// commander; any further Commander-section lines are folded into the
|
||||||
|
/// mainboard at their given quantity rather than dropped or erroring.
|
||||||
|
pub fn import_deck(&self, name: &str, text: &str) -> Result<(Deck, Vec<String>)> {
|
||||||
|
let parsed = parse_deck_text(text);
|
||||||
|
let deck = self.create_deck(name, None)?;
|
||||||
|
if let Some(fmt) = &parsed.format {
|
||||||
|
self.set_deck_format(deck.id, fmt)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut unresolved = Vec::new();
|
||||||
|
let mut commander_oracle_id: Option<String> = None;
|
||||||
|
|
||||||
|
for (i, line) in parsed.commander.iter().enumerate() {
|
||||||
|
match self.find_oracle_id_by_name(&line.name)? {
|
||||||
|
Some(oid) => {
|
||||||
|
let is_first = i == 0;
|
||||||
|
self.set_deck_card(deck.id, &oid, line.quantity, is_first, false)?;
|
||||||
|
if is_first {
|
||||||
|
commander_oracle_id = Some(oid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => unresolved.push(line.name.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(oid) = &commander_oracle_id {
|
||||||
|
self.set_deck_commander(deck.id, Some(oid))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in &parsed.mainboard {
|
||||||
|
match self.find_oracle_id_by_name(&line.name)? {
|
||||||
|
Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, false)?,
|
||||||
|
None => unresolved.push(line.name.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for line in &parsed.maybeboard {
|
||||||
|
match self.find_oracle_id_by_name(&line.name)? {
|
||||||
|
Some(oid) => self.set_deck_card(deck.id, &oid, line.quantity, false, true)?,
|
||||||
|
None => unresolved.push(line.name.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deck = self
|
||||||
|
.get_deck(deck.id)?
|
||||||
|
.expect("just-imported deck must exist");
|
||||||
|
Ok((deck, unresolved))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a deck's current contents as export text.
|
||||||
|
pub fn export_deck_text(&self, deck_id: i64) -> Result<String> {
|
||||||
|
let deck = self
|
||||||
|
.get_deck(deck_id)?
|
||||||
|
.ok_or_else(|| crate::error::CoreError::Other(format!("deck {deck_id} not found")))?;
|
||||||
|
let contents = self.deck_contents(deck_id)?;
|
||||||
|
Ok(format_deck_text(&deck, &contents))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a plain-text library list, adding each resolved card's
|
||||||
|
/// quantity to existing ownership (additive via [`Catalog::add_owned`]).
|
||||||
|
/// Returns `(name, new_total_quantity_owned)` per applied line, plus
|
||||||
|
/// unresolved names.
|
||||||
|
pub fn import_library_list(&self, text: &str) -> Result<(Vec<(String, i64)>, Vec<String>)> {
|
||||||
|
let lines = parse_library_text(text);
|
||||||
|
let mut added = Vec::new();
|
||||||
|
let mut unresolved = Vec::new();
|
||||||
|
for line in &lines {
|
||||||
|
match self.find_oracle_id_by_name(&line.name)? {
|
||||||
|
Some(oid) => {
|
||||||
|
let new_qty = self.add_owned(&oid, line.quantity)?;
|
||||||
|
added.push((line.name.clone(), new_qty));
|
||||||
|
}
|
||||||
|
None => unresolved.push(line.name.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((added, unresolved))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the given owned cards (name + current quantity_owned) as
|
||||||
|
/// library-export text.
|
||||||
|
pub fn export_library_text(&self, oracle_ids: &[String]) -> Result<String> {
|
||||||
|
let mut entries = Vec::with_capacity(oracle_ids.len());
|
||||||
|
for oid in oracle_ids {
|
||||||
|
let row: Option<(String, i64)> = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
&format!(
|
||||||
|
"SELECT oc.name, lc.quantity_owned FROM {REF_DB}.oracle_cards oc
|
||||||
|
JOIN library_cards lc ON lc.oracle_id = oc.oracle_id
|
||||||
|
WHERE oc.oracle_id = ?1"
|
||||||
|
),
|
||||||
|
params![oid],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if let Some(entry) = row {
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
|
||||||
|
Ok(format_library_text(&entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::test_support;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_card_line_accepts_bare_and_x_prefixed_quantities() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_card_line("4 Lightning Bolt"),
|
||||||
|
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_card_line("4x Lightning Bolt"),
|
||||||
|
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_card_line("4× Lightning Bolt"),
|
||||||
|
Some(DecklistLine { quantity: 4, name: "Lightning Bolt".into() })
|
||||||
|
);
|
||||||
|
assert_eq!(parse_card_line("not a card line"), None);
|
||||||
|
assert_eq!(parse_card_line("0 Lightning Bolt"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_deck_text_tolerates_blanks_comments_and_sideboard_alias() {
|
||||||
|
let text = "// mtgc deck export\n// Format: Commander\n\nCommander\n1 Krenko, Mob Boss\n\nDeck\n1 Sol Ring\n\nSideboard\n1 Some Card\n";
|
||||||
|
let parsed = parse_deck_text(text);
|
||||||
|
assert_eq!(parsed.format.as_deref(), Some("Commander"));
|
||||||
|
assert_eq!(
|
||||||
|
parsed.commander,
|
||||||
|
vec![DecklistLine { quantity: 1, name: "Krenko, Mob Boss".into() }]
|
||||||
|
);
|
||||||
|
assert_eq!(parsed.mainboard, vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.maybeboard,
|
||||||
|
vec![DecklistLine { quantity: 1, name: "Some Card".into() }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_deck_text_defaults_unheaded_lines_to_mainboard() {
|
||||||
|
let parsed = parse_deck_text("4 Lightning Bolt\n1 Sol Ring\n");
|
||||||
|
assert_eq!(parsed.mainboard.len(), 2);
|
||||||
|
assert!(parsed.commander.is_empty());
|
||||||
|
assert!(parsed.maybeboard.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_deck_text_omits_empty_sections() {
|
||||||
|
let deck = Deck {
|
||||||
|
id: 1,
|
||||||
|
name: "Test".into(),
|
||||||
|
format: String::new(),
|
||||||
|
commander_oracle_id: None,
|
||||||
|
notes: String::new(),
|
||||||
|
};
|
||||||
|
let text = format_deck_text(&deck, &[]);
|
||||||
|
assert_eq!(text, "// mtgc deck export\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_deck_resolves_names_sets_format_and_commander_and_reports_unresolved() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
let text = "// Format: Commander\n\nCommander\n1 Serra Angel\n\nDeck\n4 Lightning Bolt\n1 Totally Fake Card\n\nMaybeboard\n1 Sol Ring\n";
|
||||||
|
|
||||||
|
let (deck, unresolved) = cat.import_deck("Imported", text).unwrap();
|
||||||
|
assert_eq!(deck.format, "Commander");
|
||||||
|
assert_eq!(deck.commander_oracle_id.as_deref(), Some("serra-angel"));
|
||||||
|
assert_eq!(unresolved, vec!["Totally Fake Card".to_string()]);
|
||||||
|
|
||||||
|
let contents = cat.deck_contents(deck.id).unwrap();
|
||||||
|
let bolt = contents.iter().find(|e| e.card.oracle_id == "lightning-bolt").unwrap();
|
||||||
|
assert_eq!(bolt.quantity, 4);
|
||||||
|
assert!(!bolt.is_commander);
|
||||||
|
assert!(!bolt.is_maybeboard);
|
||||||
|
|
||||||
|
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
|
||||||
|
assert!(sol.is_maybeboard);
|
||||||
|
|
||||||
|
let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap();
|
||||||
|
assert!(angel.is_commander);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_deck_extra_commander_lines_fold_into_mainboard() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
let text = "Commander\n1 Serra Angel\n1 Sol Ring\n";
|
||||||
|
let (deck, unresolved) = cat.import_deck("Two Commanders", text).unwrap();
|
||||||
|
assert!(unresolved.is_empty());
|
||||||
|
|
||||||
|
let contents = cat.deck_contents(deck.id).unwrap();
|
||||||
|
let angel = contents.iter().find(|e| e.card.oracle_id == "serra-angel").unwrap();
|
||||||
|
assert!(angel.is_commander);
|
||||||
|
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
|
||||||
|
assert!(!sol.is_commander);
|
||||||
|
assert!(!sol.is_maybeboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_deck_text_round_trips_through_parser() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
let deck = cat.create_deck("Round Trip", Some("serra-angel")).unwrap();
|
||||||
|
cat.set_deck_format(deck.id, "Commander").unwrap();
|
||||||
|
cat.set_deck_card(deck.id, "serra-angel", 1, true, false).unwrap();
|
||||||
|
cat.set_deck_card(deck.id, "lightning-bolt", 4, false, false).unwrap();
|
||||||
|
cat.set_deck_card(deck.id, "sol-ring", 1, false, true).unwrap();
|
||||||
|
|
||||||
|
let text = cat.export_deck_text(deck.id).unwrap();
|
||||||
|
let parsed = parse_deck_text(&text);
|
||||||
|
assert_eq!(parsed.format.as_deref(), Some("Commander"));
|
||||||
|
assert_eq!(
|
||||||
|
parsed.commander,
|
||||||
|
vec![DecklistLine { quantity: 1, name: "Serra Angel".into() }]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.mainboard,
|
||||||
|
vec![DecklistLine { quantity: 4, name: "Lightning Bolt".into() }]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.maybeboard,
|
||||||
|
vec![DecklistLine { quantity: 1, name: "Sol Ring".into() }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_library_list_adds_to_existing_ownership_and_reports_unresolved() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
cat.add_owned("lightning-bolt", 2).unwrap();
|
||||||
|
|
||||||
|
let text = "2 Lightning Bolt\n1 Sol Ring\n1 Not A Real Card\n";
|
||||||
|
let (added, unresolved) = cat.import_library_list(text).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(unresolved, vec!["Not A Real Card".to_string()]);
|
||||||
|
let bolt_added = added.iter().find(|(n, _)| n == "Lightning Bolt").unwrap();
|
||||||
|
assert_eq!(bolt_added.1, 4); // 2 existing + 2 imported
|
||||||
|
let sol_added = added.iter().find(|(n, _)| n == "Sol Ring").unwrap();
|
||||||
|
assert_eq!(sol_added.1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_library_text_formats_only_requested_cards() {
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
cat.add_owned("lightning-bolt", 3).unwrap();
|
||||||
|
cat.add_owned("sol-ring", 1).unwrap();
|
||||||
|
|
||||||
|
let text = cat.export_library_text(&["lightning-bolt".to_string()]).unwrap();
|
||||||
|
assert!(text.contains("3 Lightning Bolt"));
|
||||||
|
assert!(!text.contains("Sol Ring"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ use rusqlite::{params, OptionalExtension};
|
|||||||
|
|
||||||
use crate::db::REF_DB;
|
use crate::db::REF_DB;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::model::{CardRef, ColorSet, Deck, DeckEntry};
|
use crate::model::{CardRef, ColorSet, Deck, DeckEntry, Legalities};
|
||||||
use crate::Catalog;
|
use crate::Catalog;
|
||||||
|
|
||||||
impl Catalog {
|
impl Catalog {
|
||||||
@@ -114,8 +114,8 @@ impl Catalog {
|
|||||||
let mut stmt = self.conn.prepare(&format!(
|
let mut stmt = self.conn.prepare(&format!(
|
||||||
"SELECT dc.quantity, dc.is_commander, COALESCE(lc.quantity_owned, 0),
|
"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.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
||||||
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, {price},
|
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
|
||||||
dc.is_maybeboard
|
oc.legalities, {price}, dc.is_maybeboard
|
||||||
FROM deck_cards dc
|
FROM deck_cards dc
|
||||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
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
|
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
|
||||||
@@ -142,9 +142,10 @@ impl Catalog {
|
|||||||
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
|
back_image_uri: r.get::<_, Option<String>>(10)?.unwrap_or_default(),
|
||||||
produced_mana: ColorSet(r.get::<_, i64>(11)? as u8),
|
produced_mana: ColorSet(r.get::<_, i64>(11)? as u8),
|
||||||
produces_colorless: r.get::<_, i64>(12)? != 0,
|
produces_colorless: r.get::<_, i64>(12)? != 0,
|
||||||
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
|
legalities: Legalities(r.get::<_, i64>(13)?),
|
||||||
|
price_usd: r.get::<_, Option<f64>>(14)?.unwrap_or_default(),
|
||||||
},
|
},
|
||||||
is_maybeboard: r.get::<_, i64>(14)? != 0,
|
is_maybeboard: r.get::<_, i64>(15)? != 0,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||||
@@ -236,6 +237,33 @@ mod tests {
|
|||||||
assert!(!row.is_over_assigned());
|
assert!(!row.is_over_assigned());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legalities_are_plumbed_through_deck_contents() {
|
||||||
|
use crate::model::LegalStatus;
|
||||||
|
|
||||||
|
let fx = test_support::make();
|
||||||
|
let cat = &fx.catalog;
|
||||||
|
// 2 (Legal) in the "commander" slot (bit offset 12, per LEGALITY_FORMATS).
|
||||||
|
cat.connection()
|
||||||
|
.execute(
|
||||||
|
"UPDATE ref.oracle_cards SET legalities = 4096 WHERE oracle_id = 'sol-ring'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let d = cat.create_deck("Legality Check", None).unwrap();
|
||||||
|
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
|
||||||
|
cat.set_deck_card(d.id, "llanowar-elves", 1, false, false).unwrap();
|
||||||
|
|
||||||
|
let contents = cat.deck_contents(d.id).unwrap();
|
||||||
|
let sol = contents.iter().find(|e| e.card.oracle_id == "sol-ring").unwrap();
|
||||||
|
assert_eq!(sol.card.legalities.status_for("Commander"), LegalStatus::Legal);
|
||||||
|
|
||||||
|
// Fixture cards default to no legality data at all.
|
||||||
|
let elves = contents.iter().find(|e| e.card.oracle_id == "llanowar-elves").unwrap();
|
||||||
|
assert_eq!(elves.card.legalities.status_for("Commander"), LegalStatus::NotLegal);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn over_assignment_detected_across_decks() {
|
fn over_assignment_detected_across_decks() {
|
||||||
let fx = test_support::make();
|
let fx = test_support::make();
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ impl Catalog {
|
|||||||
let mut sql = format!(
|
let mut sql = format!(
|
||||||
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
||||||
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
|
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
|
||||||
oc.produced_mana, oc.produces_colorless, {price}
|
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
|
||||||
FROM library_cards lc
|
FROM library_cards lc
|
||||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
||||||
WHERE lc.quantity_owned > 0",
|
WHERE lc.quantity_owned > 0",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use crate::db::open_reference_for_build;
|
use crate::db::open_reference_for_build;
|
||||||
use crate::error::{CoreError, Result};
|
use crate::error::{CoreError, Result};
|
||||||
use crate::model::ColorSet;
|
use crate::model::{ColorSet, Legalities};
|
||||||
|
|
||||||
const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data";
|
const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data";
|
||||||
const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)";
|
const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)";
|
||||||
@@ -434,6 +434,10 @@ struct ScryOracle {
|
|||||||
/// top-level-only Scryfall field — not per-face, even on DFCs.
|
/// top-level-only Scryfall field — not per-face, even on DFCs.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
produced_mana: Option<Vec<String>>,
|
produced_mana: Option<Vec<String>>,
|
||||||
|
/// Format-slug -> status-string (e.g. "legal"/"not_legal"/"banned"/
|
||||||
|
/// "restricted"). See [`crate::model::Legalities`].
|
||||||
|
#[serde(default)]
|
||||||
|
legalities: std::collections::HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -453,8 +457,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
|||||||
let mut ins_card = conn.prepare(
|
let mut ins_card = conn.prepare(
|
||||||
"INSERT OR IGNORE INTO oracle_cards
|
"INSERT OR IGNORE INTO oracle_cards
|
||||||
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
|
(oracle_id,name,name_lower,mana_value,colors,color_identity,type_line,oracle_text,
|
||||||
produced_mana,produces_colorless)
|
produced_mana,produces_colorless,legalities)
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",
|
||||||
)?;
|
)?;
|
||||||
let mut ins_fts =
|
let mut ins_fts =
|
||||||
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
|
conn.prepare("INSERT INTO oracle_names_fts(name,oracle_id) VALUES (?1,?2)")?;
|
||||||
@@ -533,6 +537,8 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
|||||||
let produced_mana = ColorSet::from_letters(produced);
|
let produced_mana = ColorSet::from_letters(produced);
|
||||||
let produces_colorless = produced.iter().any(|s| s.eq_ignore_ascii_case("C"));
|
let produces_colorless = produced.iter().any(|s| s.eq_ignore_ascii_case("C"));
|
||||||
|
|
||||||
|
let legalities = Legalities::from_scryfall(&c.legalities);
|
||||||
|
|
||||||
let changed = ins_card.execute(params![
|
let changed = ins_card.execute(params![
|
||||||
oracle_id,
|
oracle_id,
|
||||||
c.name,
|
c.name,
|
||||||
@@ -544,6 +550,7 @@ fn load_oracle_cards(conn: &Connection, reader: impl Read, cancel: &CancelToken)
|
|||||||
oracle_text,
|
oracle_text,
|
||||||
produced_mana.0 as i64,
|
produced_mana.0 as i64,
|
||||||
produces_colorless as i64,
|
produces_colorless as i64,
|
||||||
|
legalities.0,
|
||||||
])?;
|
])?;
|
||||||
// Only emit satellite rows the first time we see this oracle_id.
|
// Only emit satellite rows the first time we see this oracle_id.
|
||||||
if changed == 1 {
|
if changed == 1 {
|
||||||
@@ -922,6 +929,48 @@ mod tests {
|
|||||||
assert_eq!(vanilla_colorless, 0); // no produced_mana field at all
|
assert_eq!(vanilla_colorless, 0); // no produced_mana field at all
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captures_legalities() {
|
||||||
|
let oracle = r#"[
|
||||||
|
{"oracle_id":"oid-banned","name":"Balance","cmc":1.0,"color_identity":["W"],"keywords":[],
|
||||||
|
"legalities":{"standard":"not_legal","modern":"banned","legacy":"banned","vintage":"restricted","commander":"banned"}},
|
||||||
|
{"oracle_id":"oid-legal","name":"Lightning Bolt","cmc":1.0,"color_identity":["R"],"keywords":[],
|
||||||
|
"legalities":{"modern":"legal","legacy":"legal","vintage":"legal","pauper":"legal"}},
|
||||||
|
{"oracle_id":"oid-nolegal","name":"Some Test Card","cmc":1.0,"color_identity":[],"keywords":[]}
|
||||||
|
]"#;
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let dest = dir.path().join("reference.db");
|
||||||
|
let conn = open_reference_for_build(&dest).unwrap();
|
||||||
|
|
||||||
|
load_oracle_cards(&conn, oracle.as_bytes(), &CancelToken::new()).unwrap();
|
||||||
|
|
||||||
|
let fetch = |id: &str| -> Legalities {
|
||||||
|
Legalities(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT legalities FROM oracle_cards WHERE oracle_id=?1",
|
||||||
|
params![id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let balance = fetch("oid-banned");
|
||||||
|
assert_eq!(balance.status_for("Vintage"), crate::model::LegalStatus::Restricted);
|
||||||
|
assert_eq!(balance.status_for("Modern"), crate::model::LegalStatus::Banned);
|
||||||
|
assert_eq!(balance.status_for("Standard"), crate::model::LegalStatus::NotLegal);
|
||||||
|
|
||||||
|
let bolt = fetch("oid-legal");
|
||||||
|
assert_eq!(bolt.status_for("Modern"), crate::model::LegalStatus::Legal);
|
||||||
|
assert_eq!(bolt.status_for("Standard"), crate::model::LegalStatus::NotLegal); // absent key
|
||||||
|
|
||||||
|
let vanilla = fetch("oid-nolegal");
|
||||||
|
for (_, label) in crate::model::LEGALITY_FORMATS {
|
||||||
|
assert_eq!(vanilla.status_for(label), crate::model::LegalStatus::NotLegal); // no legalities object at all
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn captures_back_face_image_for_double_faced_cards() {
|
fn captures_back_face_image_for_double_faced_cards() {
|
||||||
// Transform/MDFC cards: two `card_faces`, each with its own image.
|
// Transform/MDFC cards: two `card_faces`, each with its own image.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! unit-testable and reusable when the app is ported to mobile.
|
//! unit-testable and reusable when the app is ported to mobile.
|
||||||
|
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
pub mod decklist;
|
||||||
pub mod decks;
|
pub mod decks;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
@@ -20,7 +21,8 @@ use rusqlite::Connection;
|
|||||||
|
|
||||||
pub use error::{CoreError, Result};
|
pub use error::{CoreError, Result};
|
||||||
pub use model::{
|
pub use model::{
|
||||||
CardRef, ColorSet, Deck, DeckEntry, LibraryCard, LibraryFacets, LibraryFilter, LibraryStats,
|
CardRef, ColorSet, Deck, DeckEntry, LegalStatus, Legalities, LibraryCard, LibraryFacets,
|
||||||
|
LibraryFilter, LibraryStats,
|
||||||
};
|
};
|
||||||
pub use search::NameIndex;
|
pub use search::NameIndex;
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ use rusqlite::{params, OptionalExtension};
|
|||||||
|
|
||||||
use crate::db::REF_DB;
|
use crate::db::REF_DB;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::model::{CardRef, ColorSet, LibraryCard, LibraryFacets, LibraryStats, WUBRG};
|
use crate::model::{CardRef, ColorSet, Legalities, LibraryCard, LibraryFacets, LibraryStats, WUBRG};
|
||||||
use crate::Catalog;
|
use crate::Catalog;
|
||||||
|
|
||||||
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
|
/// Map a row shaped `(oracle_id, quantity_owned, location, name, mana_value,
|
||||||
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
|
/// colors, color_identity, type_line, image_uri, back_image_uri, produced_mana,
|
||||||
/// produces_colorless, price_usd)` into its library parts. Shared by
|
/// produces_colorless, legalities, price_usd)` into its library parts. Shared
|
||||||
/// [`Catalog::list_library`] and the criteria search.
|
/// by [`Catalog::list_library`] and the criteria search.
|
||||||
pub(crate) fn row_to_library_parts(
|
pub(crate) fn row_to_library_parts(
|
||||||
r: &rusqlite::Row,
|
r: &rusqlite::Row,
|
||||||
) -> rusqlite::Result<(String, i64, String, CardRef)> {
|
) -> rusqlite::Result<(String, i64, String, CardRef)> {
|
||||||
@@ -32,7 +32,8 @@ pub(crate) fn row_to_library_parts(
|
|||||||
back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
back_image_uri: r.get::<_, Option<String>>(9)?.unwrap_or_default(),
|
||||||
produced_mana: ColorSet(r.get::<_, i64>(10)? as u8),
|
produced_mana: ColorSet(r.get::<_, i64>(10)? as u8),
|
||||||
produces_colorless: r.get::<_, i64>(11)? != 0,
|
produces_colorless: r.get::<_, i64>(11)? != 0,
|
||||||
price_usd: r.get::<_, Option<f64>>(12)?.unwrap_or_default(),
|
legalities: Legalities(r.get::<_, i64>(12)?),
|
||||||
|
price_usd: r.get::<_, Option<f64>>(13)?.unwrap_or_default(),
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -75,7 +76,7 @@ impl Catalog {
|
|||||||
let mut stmt = self.conn.prepare(&format!(
|
let mut stmt = self.conn.prepare(&format!(
|
||||||
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
"SELECT lc.oracle_id, lc.quantity_owned, lc.location,
|
||||||
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
|
oc.name, oc.mana_value, oc.colors, oc.color_identity, oc.type_line, {image}, {back},
|
||||||
oc.produced_mana, oc.produces_colorless, {price}
|
oc.produced_mana, oc.produces_colorless, oc.legalities, {price}
|
||||||
FROM library_cards lc
|
FROM library_cards lc
|
||||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = lc.oracle_id
|
||||||
WHERE lc.quantity_owned > 0
|
WHERE lc.quantity_owned > 0
|
||||||
|
|||||||
@@ -75,6 +75,73 @@ impl ColorSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The 8 formats this app tracks legality for, in a fixed order that defines
|
||||||
|
/// their bit position within [`Legalities`] (2 bits each, in order). This
|
||||||
|
/// order must never change once shipped — it's baked into every ingested
|
||||||
|
/// reference.db. `.0` is the Scryfall `legalities` JSON key; `.1` is the
|
||||||
|
/// display label used throughout the app (matches DeckView's
|
||||||
|
/// `FORMATS`/`COMMANDER_FORMATS`).
|
||||||
|
pub const LEGALITY_FORMATS: [(&str, &str); 8] = [
|
||||||
|
("standard", "Standard"),
|
||||||
|
("pioneer", "Pioneer"),
|
||||||
|
("modern", "Modern"),
|
||||||
|
("legacy", "Legacy"),
|
||||||
|
("vintage", "Vintage"),
|
||||||
|
("pauper", "Pauper"),
|
||||||
|
("commander", "Commander"),
|
||||||
|
("brawl", "Brawl"),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LegalStatus {
|
||||||
|
NotLegal,
|
||||||
|
Legal,
|
||||||
|
Banned,
|
||||||
|
Restricted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-format legality, encoded as 2 bits/format x 8 formats packed into one
|
||||||
|
/// i64 (see [`LEGALITY_FORMATS`] for bit-position order). Mirrors
|
||||||
|
/// `ColorSet`'s bitmask-newtype pattern but with 2-bit fields instead of
|
||||||
|
/// 1-bit flags, since each format has 4 possible statuses (not-legal / legal
|
||||||
|
/// / banned / restricted), not just a binary flag.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Legalities(pub i64);
|
||||||
|
|
||||||
|
impl Legalities {
|
||||||
|
/// Build from Scryfall's `legalities` object (format-slug -> status-string
|
||||||
|
/// map). Any format not present in `map`, or present with an unrecognized
|
||||||
|
/// status string, decodes as `NotLegal`.
|
||||||
|
pub fn from_scryfall(map: &std::collections::HashMap<String, String>) -> Self {
|
||||||
|
let mut bits = 0i64;
|
||||||
|
for (i, (key, _label)) in LEGALITY_FORMATS.iter().enumerate() {
|
||||||
|
let status: i64 = match map.get(*key).map(String::as_str) {
|
||||||
|
Some("legal") => 1,
|
||||||
|
Some("banned") => 2,
|
||||||
|
Some("restricted") => 3,
|
||||||
|
_ => 0, // "not_legal", or key missing entirely
|
||||||
|
};
|
||||||
|
bits |= status << (i * 2);
|
||||||
|
}
|
||||||
|
Legalities(bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the status for a given format's display label (e.g.
|
||||||
|
/// "Commander", "Modern" — matches DeckView's `FORMATS` strings).
|
||||||
|
/// Unknown labels decode as `NotLegal`.
|
||||||
|
pub fn status_for(self, format_label: &str) -> LegalStatus {
|
||||||
|
let Some(i) = LEGALITY_FORMATS.iter().position(|(_, label)| *label == format_label) else {
|
||||||
|
return LegalStatus::NotLegal;
|
||||||
|
};
|
||||||
|
match (self.0 >> (i * 2)) & 0b11 {
|
||||||
|
1 => LegalStatus::Legal,
|
||||||
|
2 => LegalStatus::Banned,
|
||||||
|
3 => LegalStatus::Restricted,
|
||||||
|
_ => LegalStatus::NotLegal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A card as returned by search / library queries. Oracle-level (one per
|
/// A card as returned by search / library queries. Oracle-level (one per
|
||||||
/// `oracle_id`), enriched with the caller's ownership + deck context where relevant.
|
/// `oracle_id`), enriched with the caller's ownership + deck context where relevant.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -102,6 +169,10 @@ pub struct CardRef {
|
|||||||
/// USD market price (Scryfall's `prices.usd`) for the same printing that
|
/// USD market price (Scryfall's `prices.usd`) for the same printing that
|
||||||
/// supplied `image_uri`, or 0 if unknown.
|
/// supplied `image_uri`, or 0 if unknown.
|
||||||
pub price_usd: f64,
|
pub price_usd: f64,
|
||||||
|
/// Per-format legality (banned/restricted/legal). See
|
||||||
|
/// [`LEGALITY_FORMATS`] for the bit-position order and
|
||||||
|
/// [`Legalities::status_for`] to decode a specific format.
|
||||||
|
pub legalities: Legalities,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A library row: a card the user owns, with quantity and its deck memberships.
|
/// A library row: a card the user owns, with quantity and its deck memberships.
|
||||||
@@ -212,3 +283,38 @@ pub struct LibraryFilter {
|
|||||||
/// Case-insensitive substring match against the card's oracle rules text.
|
/// Case-insensitive substring match against the card's oracle rules text.
|
||||||
pub oracle_text: Option<String>,
|
pub oracle_text: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legalities_round_trip_all_statuses() {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
map.insert("standard".to_string(), "legal".to_string());
|
||||||
|
map.insert("modern".to_string(), "banned".to_string());
|
||||||
|
map.insert("vintage".to_string(), "restricted".to_string());
|
||||||
|
// "pauper" deliberately absent
|
||||||
|
|
||||||
|
let legalities = Legalities::from_scryfall(&map);
|
||||||
|
|
||||||
|
assert_eq!(legalities.status_for("Standard"), LegalStatus::Legal);
|
||||||
|
assert_eq!(legalities.status_for("Modern"), LegalStatus::Banned);
|
||||||
|
assert_eq!(legalities.status_for("Vintage"), LegalStatus::Restricted);
|
||||||
|
assert_eq!(legalities.status_for("Pauper"), LegalStatus::NotLegal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legalities_unknown_format_label_is_not_legal() {
|
||||||
|
let legalities = Legalities::from_scryfall(&HashMap::new());
|
||||||
|
assert_eq!(legalities.status_for("Nonsense"), LegalStatus::NotLegal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legalities_default_is_all_not_legal() {
|
||||||
|
let legalities = Legalities::default();
|
||||||
|
assert_eq!(legalities.status_for("Commander"), LegalStatus::NotLegal);
|
||||||
|
assert_eq!(legalities.status_for("Brawl"), LegalStatus::NotLegal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use rusqlite::Connection;
|
|||||||
|
|
||||||
use crate::db::REF_DB;
|
use crate::db::REF_DB;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::model::{CardRef, ColorSet};
|
use crate::model::{CardRef, ColorSet, Legalities};
|
||||||
|
|
||||||
/// One searchable entry, carrying the full [`CardRef`] so a hit needs no extra
|
/// 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.
|
/// DB round-trip. `AsRef<str>` yields the name that nucleo matches against.
|
||||||
@@ -34,7 +34,8 @@ impl NameIndex {
|
|||||||
pub fn build(conn: &Connection) -> Result<Self> {
|
pub fn build(conn: &Connection) -> Result<Self> {
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
"SELECT oc.oracle_id, oc.name, oc.mana_value, oc.colors, oc.color_identity,
|
||||||
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless, {price}
|
oc.type_line, {image}, {back}, oc.produced_mana, oc.produces_colorless,
|
||||||
|
oc.legalities, {price}
|
||||||
FROM {REF_DB}.oracle_cards oc",
|
FROM {REF_DB}.oracle_cards oc",
|
||||||
image = crate::db::image_subquery("image_uri"),
|
image = crate::db::image_subquery("image_uri"),
|
||||||
back = crate::db::image_subquery("back_image_uri"),
|
back = crate::db::image_subquery("back_image_uri"),
|
||||||
@@ -54,7 +55,8 @@ impl NameIndex {
|
|||||||
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
back_image_uri: r.get::<_, Option<String>>(7)?.unwrap_or_default(),
|
||||||
produced_mana: ColorSet(r.get::<_, i64>(8)? as u8),
|
produced_mana: ColorSet(r.get::<_, i64>(8)? as u8),
|
||||||
produces_colorless: r.get::<_, i64>(9)? != 0,
|
produces_colorless: r.get::<_, i64>(9)? != 0,
|
||||||
price_usd: r.get::<_, Option<f64>>(10)?.unwrap_or_default(),
|
legalities: Legalities(r.get::<_, i64>(10)?),
|
||||||
|
price_usd: r.get::<_, Option<f64>>(11)?.unwrap_or_default(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ CREATE TABLE IF NOT EXISTS oracle_cards (
|
|||||||
type_line TEXT NOT NULL DEFAULT '',
|
type_line TEXT NOT NULL DEFAULT '',
|
||||||
oracle_text TEXT NOT NULL DEFAULT '',
|
oracle_text TEXT NOT NULL DEFAULT '',
|
||||||
produced_mana INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask (lands/rocks/dorks)
|
produced_mana INTEGER NOT NULL DEFAULT 0, -- WUBRG bitmask (lands/rocks/dorks)
|
||||||
produces_colorless INTEGER NOT NULL DEFAULT 0 -- boolean: can produce {C}
|
produces_colorless INTEGER NOT NULL DEFAULT 0, -- boolean: can produce {C}
|
||||||
|
legalities INTEGER NOT NULL DEFAULT 0 -- 2 bits/format x 8 formats, see model::LEGALITY_FORMATS
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_oracle_name_lower ON oracle_cards(name_lower);
|
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_ci ON oracle_cards(color_identity);
|
||||||
|
|||||||
@@ -220,3 +220,48 @@ pub fn card_decks(db: Db, oracle_id: String) -> Res<Vec<Deck>> {
|
|||||||
let s = db.lock().unwrap();
|
let s = db.lock().unwrap();
|
||||||
s.catalog()?.card_decks(&oracle_id).map_err(|e| e.to_string())
|
s.catalog()?.card_decks(&oracle_id).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DeckImportResult {
|
||||||
|
pub deck: Deck,
|
||||||
|
pub unresolved: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct LibraryImportResult {
|
||||||
|
pub added: Vec<(String, i64)>,
|
||||||
|
pub unresolved: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn export_deck(db: Db, deck_id: i64, path: String) -> Res<()> {
|
||||||
|
let s = db.lock().unwrap();
|
||||||
|
let text = s.catalog()?.export_deck_text(deck_id).map_err(|e| e.to_string())?;
|
||||||
|
std::fs::write(&path, text).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_deck(db: Db, path: String, name: String) -> Res<DeckImportResult> {
|
||||||
|
let s = db.lock().unwrap();
|
||||||
|
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
|
let (deck, unresolved) = s.catalog()?.import_deck(&name, &text).map_err(|e| e.to_string())?;
|
||||||
|
Ok(DeckImportResult { deck, unresolved })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn export_library_cards(db: Db, oracle_ids: Vec<String>, path: String) -> Res<()> {
|
||||||
|
let s = db.lock().unwrap();
|
||||||
|
let text = s
|
||||||
|
.catalog()?
|
||||||
|
.export_library_text(&oracle_ids)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
std::fs::write(&path, text).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn import_library_cards(db: Db, path: String) -> Res<LibraryImportResult> {
|
||||||
|
let s = db.lock().unwrap();
|
||||||
|
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
|
||||||
|
let (added, unresolved) = s.catalog()?.import_library_list(&text).map_err(|e| e.to_string())?;
|
||||||
|
Ok(LibraryImportResult { added, unresolved })
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ pub fn run() {
|
|||||||
commands::set_deck_card,
|
commands::set_deck_card,
|
||||||
commands::deck_contents,
|
commands::deck_contents,
|
||||||
commands::card_decks,
|
commands::card_decks,
|
||||||
|
commands::export_deck,
|
||||||
|
commands::import_deck,
|
||||||
|
commands::export_library_cards,
|
||||||
|
commands::import_library_cards,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
StatTile,
|
StatTile,
|
||||||
usePersistedState,
|
usePersistedState,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
import { CommandPalette } from "./CommandPalette";
|
||||||
import { CardEntry } from "./views/CardEntry";
|
import { CardEntry } from "./views/CardEntry";
|
||||||
import { LibraryBrowser } from "./views/LibraryBrowser";
|
import { LibraryBrowser } from "./views/LibraryBrowser";
|
||||||
import { DeckView } from "./views/DeckView";
|
import { DeckView } from "./views/DeckView";
|
||||||
@@ -399,6 +400,12 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<CommandPalette
|
||||||
|
onNavigate={setTab}
|
||||||
|
onUpdateCardData={updateCardData}
|
||||||
|
onOpenLibrary={openLibrary}
|
||||||
|
onNewLibrary={newLibrary}
|
||||||
|
/>
|
||||||
</HoverPreviewProvider>
|
</HoverPreviewProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
170
src/CommandPalette.tsx
Normal file
170
src/CommandPalette.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { api, CardRef, Deck } from "./api";
|
||||||
|
import { DialogFrame, useClickPreview } from "./components";
|
||||||
|
|
||||||
|
type Tab = "add" | "library" | "decks";
|
||||||
|
|
||||||
|
interface PaletteItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simple, dependency-free case-insensitive subsequence match — e.g. "gtd"
|
||||||
|
* matches "Go to Decks". Good enough for the small static command list and
|
||||||
|
* the deck list; card search still defers to the server-side fuzzy matcher
|
||||||
|
* via `api.searchCards`. */
|
||||||
|
function subsequenceMatch(query: string, text: string): boolean {
|
||||||
|
if (!query) return true;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
let qi = 0;
|
||||||
|
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
||||||
|
if (t[ti] === q[qi]) qi++;
|
||||||
|
}
|
||||||
|
return qi === q.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alt+/ opens a fuzzy-searchable palette of navigation commands, decks, and
|
||||||
|
* cards. Selecting a deck switches to the Decks tab with it pre-selected;
|
||||||
|
* selecting a card opens the existing click-to-zoom preview modal without
|
||||||
|
* changing tabs.
|
||||||
|
*/
|
||||||
|
export function CommandPalette({
|
||||||
|
onNavigate,
|
||||||
|
onUpdateCardData,
|
||||||
|
onOpenLibrary,
|
||||||
|
onNewLibrary,
|
||||||
|
}: {
|
||||||
|
onNavigate: (tab: Tab) => void;
|
||||||
|
onUpdateCardData: () => void;
|
||||||
|
onOpenLibrary: () => void;
|
||||||
|
onNewLibrary: () => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [decks, setDecks] = useState<Deck[]>([]);
|
||||||
|
const [cardResults, setCardResults] = useState<CardRef[]>([]);
|
||||||
|
const [sel, setSel] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { open: openCardPreview } = useClickPreview();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.altKey && e.key === "/") {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setQuery("");
|
||||||
|
setCardResults([]);
|
||||||
|
api.listDecks().then(setDecks);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return setCardResults([]);
|
||||||
|
const t = setTimeout(() => api.searchCards(q, 8).then(setCardResults), 80);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [query, open]);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticCommands: PaletteItem[] = [
|
||||||
|
{ key: "nav-add", label: "Go to Add Cards", run: () => onNavigate("add") },
|
||||||
|
{ key: "nav-library", label: "Go to Library", run: () => onNavigate("library") },
|
||||||
|
{ key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") },
|
||||||
|
{ key: "update-card-data", label: "Update card data", run: onUpdateCardData },
|
||||||
|
{ key: "open-library", label: "Switch library file…", run: onOpenLibrary },
|
||||||
|
{ key: "new-library", label: "Create new library file…", run: onNewLibrary },
|
||||||
|
];
|
||||||
|
|
||||||
|
const items: PaletteItem[] = [
|
||||||
|
...staticCommands.filter((c) => subsequenceMatch(query, c.label)),
|
||||||
|
...decks
|
||||||
|
.filter((d) => subsequenceMatch(query, d.name))
|
||||||
|
.map((d) => ({
|
||||||
|
key: `deck-${d.id}`,
|
||||||
|
label: d.name,
|
||||||
|
hint: "Deck",
|
||||||
|
run: () => {
|
||||||
|
// DeckView reads its initial selection from this same
|
||||||
|
// localStorage key on mount (usePersistedState("deck-selected"),
|
||||||
|
// src/views/DeckView.tsx). App.tsx unmounts/remounts DeckView on
|
||||||
|
// tab switch, so writing here before navigating is enough for it
|
||||||
|
// to pick up the selection on its next mount.
|
||||||
|
localStorage.setItem("deck-selected", JSON.stringify(d.id));
|
||||||
|
onNavigate("decks");
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...cardResults.map((c) => ({
|
||||||
|
key: `card-${c.oracle_id}`,
|
||||||
|
label: c.name,
|
||||||
|
hint: c.type_line,
|
||||||
|
run: () => openCardPreview(c),
|
||||||
|
})),
|
||||||
|
].slice(0, 20);
|
||||||
|
|
||||||
|
useEffect(() => setSel(0), [items.length, query]);
|
||||||
|
|
||||||
|
function pick(item: PaletteItem) {
|
||||||
|
item.run();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSel((s) => Math.min(s + 1, items.length - 1));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSel((s) => Math.max(s - 1, 0));
|
||||||
|
} else if (e.key === "Enter" && items[sel]) {
|
||||||
|
e.preventDefault();
|
||||||
|
pick(items[sel]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogFrame title="Command palette" onCancel={close} wide>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
autoFocus
|
||||||
|
style={{ width: "100%", marginBottom: 8 }}
|
||||||
|
placeholder="Type a command, deck, or card name…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<ul className="result-list" style={{ marginBottom: 0 }}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={item.key}
|
||||||
|
style={i === sel ? { background: "var(--accent-subtle)" } : undefined}
|
||||||
|
onMouseEnter={() => setSel(i)}
|
||||||
|
onClick={() => pick(item)}
|
||||||
|
>
|
||||||
|
<span className="card-name">{item.label}</span>
|
||||||
|
{item.hint && <span className="card-type">{item.hint}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</DialogFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/api.ts
45
src/api.ts
@@ -15,6 +15,7 @@ export interface CardRef {
|
|||||||
produced_mana: number; // WUBRG bitmask of mana this card can tap for
|
produced_mana: number; // WUBRG bitmask of mana this card can tap for
|
||||||
produces_colorless: boolean; // can produce {C}
|
produces_colorless: boolean; // can produce {C}
|
||||||
price_usd: number; // USD market price, or 0 if unknown
|
price_usd: number; // USD market price, or 0 if unknown
|
||||||
|
legalities: number; // 2 bits/format bitmask, see LEGALITY_FORMATS
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryCard extends CardRef {
|
export interface LibraryCard extends CardRef {
|
||||||
@@ -41,6 +42,16 @@ export interface Deck {
|
|||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeckImportResult {
|
||||||
|
deck: Deck;
|
||||||
|
unresolved: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryImportResult {
|
||||||
|
added: [string, number][];
|
||||||
|
unresolved: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LibraryFilter {
|
export interface LibraryFilter {
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
colors_exact?: number | null;
|
colors_exact?: number | null;
|
||||||
@@ -107,6 +118,32 @@ export function maskToLetters(mask: number): string {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Legality bitmask helpers ----------------------------------------------
|
||||||
|
// Mirrors mtgc-core's model::LEGALITY_FORMATS / Legalities exactly: 2 bits per
|
||||||
|
// format, in this fixed order. Do not reorder.
|
||||||
|
|
||||||
|
export const LEGALITY_FORMATS: { key: string; label: string }[] = [
|
||||||
|
{ key: "standard", label: "Standard" },
|
||||||
|
{ key: "pioneer", label: "Pioneer" },
|
||||||
|
{ key: "modern", label: "Modern" },
|
||||||
|
{ key: "legacy", label: "Legacy" },
|
||||||
|
{ key: "vintage", label: "Vintage" },
|
||||||
|
{ key: "pauper", label: "Pauper" },
|
||||||
|
{ key: "commander", label: "Commander" },
|
||||||
|
{ key: "brawl", label: "Brawl" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type LegalStatus = "not_legal" | "legal" | "banned" | "restricted";
|
||||||
|
|
||||||
|
const LEGAL_STATUS_CODES: readonly LegalStatus[] = ["not_legal", "legal", "banned", "restricted"];
|
||||||
|
|
||||||
|
export function legalStatusFor(legalities: number, formatLabel: string): LegalStatus {
|
||||||
|
const i = LEGALITY_FORMATS.findIndex((f) => f.label === formatLabel);
|
||||||
|
if (i < 0) return "not_legal";
|
||||||
|
const code = (legalities >> (i * 2)) & 0b11;
|
||||||
|
return LEGAL_STATUS_CODES[code];
|
||||||
|
}
|
||||||
|
|
||||||
// --- Commands -------------------------------------------------------------
|
// --- Commands -------------------------------------------------------------
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
@@ -150,4 +187,12 @@ export const api = {
|
|||||||
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
|
) => invoke<void>("set_deck_card", { deckId, oracleId, quantity, isCommander, isMaybeboard }),
|
||||||
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
|
deckContents: (deckId: number) => invoke<DeckEntry[]>("deck_contents", { deckId }),
|
||||||
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
|
cardDecks: (oracleId: string) => invoke<Deck[]>("card_decks", { oracleId }),
|
||||||
|
|
||||||
|
exportDeck: (deckId: number, path: string) => invoke<void>("export_deck", { deckId, path }),
|
||||||
|
importDeck: (path: string, name: string) =>
|
||||||
|
invoke<DeckImportResult>("import_deck", { path, name }),
|
||||||
|
exportLibraryCards: (oracleIds: string[], path: string) =>
|
||||||
|
invoke<void>("export_library_cards", { oracleIds, path }),
|
||||||
|
importLibraryCards: (path: string) =>
|
||||||
|
invoke<LibraryImportResult>("import_library_cards", { path }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,6 +63,26 @@ const ICON_ARROW_LEFT_CIRCLE = icon(
|
|||||||
<line x1="16" y1="12" x2="8" y2="12" />
|
<line x1="16" y1="12" x2="8" y2="12" />
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
|
const ICON_CARDS = icon(
|
||||||
|
<>
|
||||||
|
<rect x="4" y="2" width="12" height="17" rx="2" />
|
||||||
|
<rect x="8" y="5" width="12" height="17" rx="2" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
const ICON_DOWNLOAD = icon(
|
||||||
|
<>
|
||||||
|
<path d="M12 3v12" />
|
||||||
|
<polyline points="7 11 12 16 17 11" />
|
||||||
|
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
const ICON_UPLOAD = icon(
|
||||||
|
<>
|
||||||
|
<path d="M12 21V9" />
|
||||||
|
<polyline points="7 13 12 8 17 13" />
|
||||||
|
<path d="M4 18v1a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1" />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A small round icon-only button, used consistently for lightweight actions
|
* A small round icon-only button, used consistently for lightweight actions
|
||||||
@@ -108,22 +128,29 @@ export const Icons = {
|
|||||||
close: ICON_CLOSE,
|
close: ICON_CLOSE,
|
||||||
helpCircle: ICON_HELP_CIRCLE,
|
helpCircle: ICON_HELP_CIRCLE,
|
||||||
arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE,
|
arrowLeftCircle: ICON_ARROW_LEFT_CIRCLE,
|
||||||
|
cards: ICON_CARDS,
|
||||||
|
download: ICON_DOWNLOAD,
|
||||||
|
upload: ICON_UPLOAD,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Dialogs ------------------------------------------------------------
|
// --- Dialogs ------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`] and
|
* A centered modal dialog (backdrop + card) shared by [`ConfirmDialog`],
|
||||||
* [`PromptDialog`]. Dismisses on Escape or backdrop click.
|
* [`PromptDialog`], and other dialogs built outside this module (e.g. the
|
||||||
|
* command palette). Dismisses on Escape or backdrop click.
|
||||||
*/
|
*/
|
||||||
function DialogFrame({
|
export function DialogFrame({
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
wide,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
/** Wider variant for content that needs more room than a typical confirm/prompt box. */
|
||||||
|
wide?: boolean;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCancel();
|
||||||
@@ -133,7 +160,7 @@ function DialogFrame({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dialog-backdrop" onClick={onCancel}>
|
<div className="dialog-backdrop" onClick={onCancel}>
|
||||||
<div className="dialog" onClick={(e) => e.stopPropagation()}>
|
<div className={`dialog ${wide ? "dialog-wide" : ""}`} onClick={(e) => e.stopPropagation()}>
|
||||||
<h3 className="dialog-title">{title}</h3>
|
<h3 className="dialog-title">{title}</h3>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,6 +247,83 @@ export function PromptDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read-only informational dialog: a message plus an optional bulleted list
|
||||||
|
* (e.g. unresolved card names after an import). Single "OK" dismiss button. */
|
||||||
|
export function InfoDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
items,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
message?: ReactNode;
|
||||||
|
items?: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogFrame title={title} onCancel={onClose} wide={!!items?.length}>
|
||||||
|
{message && <p className="dialog-message">{message}</p>}
|
||||||
|
{items && items.length > 0 && (
|
||||||
|
<ul className="dialog-list">
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<li key={i}>{it}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="primary" onClick={onClose} autoFocus>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modal shuffle-and-draw sample hand: shows the drawn cards and lets the
|
||||||
|
* caller draw more or reshuffle, on top of a click/Escape-to-close backdrop. */
|
||||||
|
export function SampleHandModal({
|
||||||
|
hand,
|
||||||
|
libraryCount,
|
||||||
|
onDrawOne,
|
||||||
|
onNewHand,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
hand: CardRef[];
|
||||||
|
libraryCount: number;
|
||||||
|
onDrawOne: () => void;
|
||||||
|
onNewHand: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogFrame title="Sample hand" onCancel={onClose} wide>
|
||||||
|
<div className="row" style={{ gap: 10, marginBottom: 12 }}>
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
|
{libraryCount} card{libraryCount === 1 ? "" : "s"} left in library
|
||||||
|
</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button onClick={onDrawOne} disabled={libraryCount === 0}>
|
||||||
|
Draw a card
|
||||||
|
</button>
|
||||||
|
<button onClick={onNewHand}>New hand</button>
|
||||||
|
</div>
|
||||||
|
{hand.length > 0 ? (
|
||||||
|
<div className="tile-grid">
|
||||||
|
{hand.map((c, i) => (
|
||||||
|
<CardTile key={`${c.oracle_id}-${i}`} card={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="hint">Your deck has no cards to draw yet.</p>
|
||||||
|
)}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="primary" onClick={onClose} autoFocus>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</DialogFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inline editor for a card's free-text storage location (e.g. "Box 1").
|
* 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.
|
* Saves on blur; used for owned cards that aren't in any deck.
|
||||||
@@ -719,10 +823,12 @@ function primaryType(typeLine: string): string {
|
|||||||
return words[words.length - 1] || "Other";
|
return words[words.length - 1] || "Other";
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstSubtype(typeLine: string): string {
|
/** e.g. "Creature — Chimera" — prefixed with the primary type so cards group
|
||||||
|
* by (type, subtype) rather than colliding on a subtype shared across types. */
|
||||||
|
function subtypeLabel(typeLine: string): string {
|
||||||
const parts = typeLine.split("—");
|
const parts = typeLine.split("—");
|
||||||
if (parts.length < 2) return "No subtype";
|
const subtype = parts.length < 2 ? "No subtype" : parts[1].trim().split(/\s+/)[0] || "No subtype";
|
||||||
return parts[1].trim().split(/\s+/)[0] || "No subtype";
|
return `${primaryType(typeLine)} — ${subtype}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function colorLabel(mask: number): string {
|
function colorLabel(mask: number): string {
|
||||||
@@ -741,7 +847,7 @@ export function groupCards<T extends CardRef>(
|
|||||||
mode === "type"
|
mode === "type"
|
||||||
? primaryType(c.type_line)
|
? primaryType(c.type_line)
|
||||||
: mode === "subtype"
|
: mode === "subtype"
|
||||||
? firstSubtype(c.type_line)
|
? subtypeLabel(c.type_line)
|
||||||
: mode === "cmc"
|
: mode === "cmc"
|
||||||
? `Mana value ${c.mana_value}`
|
? `Mana value ${c.mana_value}`
|
||||||
: colorLabel(c.color_identity);
|
: colorLabel(c.color_identity);
|
||||||
@@ -962,6 +1068,7 @@ export function CardTile({
|
|||||||
card,
|
card,
|
||||||
corner,
|
corner,
|
||||||
cornerLeft,
|
cornerLeft,
|
||||||
|
cornerBottomLeft,
|
||||||
footer,
|
footer,
|
||||||
selected,
|
selected,
|
||||||
}: {
|
}: {
|
||||||
@@ -969,6 +1076,8 @@ export function CardTile({
|
|||||||
corner?: ReactNode;
|
corner?: ReactNode;
|
||||||
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
|
/** Rendered top-left, e.g. a selection checkbox (see `selected`). */
|
||||||
cornerLeft?: ReactNode;
|
cornerLeft?: ReactNode;
|
||||||
|
/** Rendered bottom-left, e.g. a secondary per-card action button. */
|
||||||
|
cornerBottomLeft?: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
|
/** Highlights the tile and keeps `cornerLeft` visible without hovering. */
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
@@ -990,6 +1099,7 @@ export function CardTile({
|
|||||||
)}
|
)}
|
||||||
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>}
|
{cornerLeft && <div className="tile-corner-left">{cornerLeft}</div>}
|
||||||
{corner && <div className="tile-corner">{corner}</div>}
|
{corner && <div className="tile-corner">{corner}</div>}
|
||||||
|
{cornerBottomLeft && <div className="tile-corner-bottom-left">{cornerBottomLeft}</div>}
|
||||||
{card.price_usd > 0 && (
|
{card.price_usd > 0 && (
|
||||||
<div className="tile-corner-bottom-right">
|
<div className="tile-corner-bottom-right">
|
||||||
<span className="tile-badge">${card.price_usd.toFixed(2)}</span>
|
<span className="tile-badge">${card.price_usd.toFixed(2)}</span>
|
||||||
|
|||||||
156
src/styles.css
156
src/styles.css
@@ -621,14 +621,14 @@ h2 {
|
|||||||
letter-spacing: -0.2px;
|
letter-spacing: -0.2px;
|
||||||
}
|
}
|
||||||
h3 {
|
h3 {
|
||||||
margin: 18px 0 10px;
|
margin: 20px 0 12px;
|
||||||
font-size: 13px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--faint);
|
color: var(--text-strong);
|
||||||
border-bottom: 1px solid var(--border-soft);
|
border-bottom: 2px solid var(--border-soft);
|
||||||
padding-bottom: 8px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
h4 {
|
h4 {
|
||||||
margin: 14px 0 7px;
|
margin: 14px 0 7px;
|
||||||
@@ -636,6 +636,26 @@ h4 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-hover);
|
color: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "Group by" sub-headers (e.g. per card type/subtype/CMC/color within a
|
||||||
|
* section) — sticky within .content's scroll area so the current group
|
||||||
|
* stays visible while scrolling through a long list, instead of scrolling
|
||||||
|
* out of view along with its cards. Applied via class (not the bare h3/h4
|
||||||
|
* tag) since h3 is also used for .dialog-title, which must not be sticky. */
|
||||||
|
.group-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
background: var(--bg-app);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
color: var(--text-strong);
|
||||||
|
border-bottom: 2px solid var(--accent);
|
||||||
|
}
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
@@ -660,6 +680,86 @@ h4 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Deck format-legality panel: a prominent, colorful tile, matching
|
||||||
|
.commander-hero / .deck-stats in weight, above the fold on every deck. */
|
||||||
|
.legality-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.legality-tile.legal {
|
||||||
|
background: rgba(75, 176, 115, 0.16);
|
||||||
|
border-color: rgba(75, 176, 115, 0.4);
|
||||||
|
}
|
||||||
|
.legality-tile.illegal {
|
||||||
|
background: rgba(216, 80, 63, 0.16);
|
||||||
|
border-color: rgba(216, 80, 63, 0.4);
|
||||||
|
}
|
||||||
|
.legality-tile.unchecked {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-color: var(--border-soft);
|
||||||
|
}
|
||||||
|
.legality-tile-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.legality-tile-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.legality-tile.legal .legality-tile-icon {
|
||||||
|
background: var(--success);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.legality-tile.illegal .legality-tile-icon {
|
||||||
|
background: var(--danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.legality-tile.unchecked .legality-tile-icon {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.legality-tile-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.legality-tile-status {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.legality-tile.legal .legality-tile-status {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
.legality-tile.illegal .legality-tile-status {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.legality-tile-detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.legality-tile-reasons {
|
||||||
|
margin: 0 0 0 54px;
|
||||||
|
padding-left: 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Quantity stepper: [− N +] for owned-copy counts. Used on Add Cards
|
Quantity stepper: [− N +] for owned-copy counts. Used on Add Cards
|
||||||
and both Library views (list + tile) for a consistent look.
|
and both Library views (list + tile) for a consistent look.
|
||||||
@@ -844,6 +944,11 @@ li:focus-within .row-actions {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
.dialog-wide {
|
||||||
|
width: min(900px, 92vw);
|
||||||
|
max-height: 86vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
.dialog-title {
|
.dialog-title {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -860,6 +965,18 @@ li:focus-within .row-actions {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.dialog-list {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
padding-left: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
.dialog-field {
|
.dialog-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1210,7 +1327,10 @@ tbody tr:hover {
|
|||||||
.preview-modal-backdrop {
|
.preview-modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 300;
|
/* Above .dialog-backdrop (400) — a card can now be clicked to zoom from
|
||||||
|
inside a dialog (e.g. the sample hand modal), and the zoomed preview
|
||||||
|
must stay on top of the dialog it was opened from. */
|
||||||
|
z-index: 450;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1370,6 +1490,17 @@ tbody tr:hover {
|
|||||||
.tile:focus-within .tile-corner-bottom-right {
|
.tile:focus-within .tile-corner-bottom-right {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
.tile-corner-bottom-left {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
left: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.tile:hover .tile-corner-bottom-left,
|
||||||
|
.tile:focus-within .tile-corner-bottom-left {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
.tile-selected .tile-img {
|
.tile-selected .tile-img {
|
||||||
box-shadow: 0 0 0 3px var(--accent), var(--shadow-md);
|
box-shadow: 0 0 0 3px var(--accent), var(--shadow-md);
|
||||||
}
|
}
|
||||||
@@ -1450,6 +1581,14 @@ tbody tr:hover {
|
|||||||
border: 1px solid var(--border-soft);
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
/* Separates the deck-level info panels (commander, format, analysis,
|
||||||
|
legality) from the card-management controls + lists below. */
|
||||||
|
.deck-section-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
margin: 4px 0 18px;
|
||||||
}
|
}
|
||||||
.commander-box {
|
.commander-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1558,3 +1697,4 @@ tbody tr:hover {
|
|||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||||
import { api, CardRef, COLORS, Deck, DeckEntry } from "../api";
|
import { api, CardRef, COLORS, Deck, DeckEntry, DeckImportResult, legalStatusFor } from "../api";
|
||||||
import {
|
import {
|
||||||
CardSearchBox,
|
CardSearchBox,
|
||||||
CardThumb,
|
CardThumb,
|
||||||
@@ -11,9 +12,11 @@ import {
|
|||||||
GroupMode,
|
GroupMode,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
|
InfoDialog,
|
||||||
ManaCurve,
|
ManaCurve,
|
||||||
PromptDialog,
|
PromptDialog,
|
||||||
QtyStepper,
|
QtyStepper,
|
||||||
|
SampleHandModal,
|
||||||
StatTile,
|
StatTile,
|
||||||
ViewToggle,
|
ViewToggle,
|
||||||
groupCards,
|
groupCards,
|
||||||
@@ -34,6 +37,8 @@ type DeckDialog =
|
|||||||
| { kind: "delete"; deck: Deck }
|
| { kind: "delete"; deck: Deck }
|
||||||
| { kind: "rename"; deck: Deck }
|
| { kind: "rename"; deck: Deck }
|
||||||
| { kind: "duplicate"; deck: Deck }
|
| { kind: "duplicate"; deck: Deck }
|
||||||
|
| { kind: "import-name"; suggestedName: string; path: string }
|
||||||
|
| { kind: "import-result"; result: DeckImportResult }
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
function friendlyDeckError(err: unknown): string {
|
function friendlyDeckError(err: unknown): string {
|
||||||
@@ -181,6 +186,8 @@ function CommanderHero({ card, onClear }: { card: DeckEntry; onClear: () => void
|
|||||||
// the Library tab, so a mistaken removal can be undone with +.
|
// the Library tab, so a mistaken removal can be undone with +.
|
||||||
const REMOVAL_GRACE_MS = 5000;
|
const REMOVAL_GRACE_MS = 5000;
|
||||||
|
|
||||||
|
const STARTING_HAND_SIZE = 7;
|
||||||
|
|
||||||
const FORMATS = [
|
const FORMATS = [
|
||||||
"",
|
"",
|
||||||
"Commander",
|
"Commander",
|
||||||
@@ -195,10 +202,131 @@ const FORMATS = [
|
|||||||
];
|
];
|
||||||
const COMMANDER_FORMATS = ["Commander", "Brawl"];
|
const COMMANDER_FORMATS = ["Commander", "Brawl"];
|
||||||
|
|
||||||
|
interface DeckLegality {
|
||||||
|
status: "legal" | "illegal" | "unchecked";
|
||||||
|
reasons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMANDER_DECK_SIZE = 100;
|
||||||
|
const CONSTRUCTED_MIN_DECK_SIZE = 60;
|
||||||
|
const CONSTRUCTED_MAX_COPIES = 4;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deck editor. A deck's cards are split into two groups — "In your library"
|
* Format-legality check for the deck as a whole: structural deck-building
|
||||||
* (owned) and "Not owned" — each with its own search box, and each groupable by
|
* rules (size, singleton/copy limits, commander color identity) plus
|
||||||
* card attribute. Deck-level format + commander metadata sit at the top.
|
* Scryfall's per-format banned/restricted lists (via each card's
|
||||||
|
* `legalities` bitmask). Blank format and "Other" run no checks at all —
|
||||||
|
* "unchecked" is a neutral state, distinct from "illegal".
|
||||||
|
*/
|
||||||
|
function computeDeckLegality(deck: Deck | null | undefined, contents: DeckEntry[]): DeckLegality {
|
||||||
|
if (!deck || deck.format === "" || deck.format === "Other") {
|
||||||
|
return { status: "unchecked", reasons: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasons: string[] = [];
|
||||||
|
const cards = contents.filter((e) => !e.is_maybeboard);
|
||||||
|
const isBasicLand = (e: DeckEntry) => e.type_line.includes("Basic Land");
|
||||||
|
|
||||||
|
// NOTE: a handful of real cards (Relentless Rats, Persistent Petitioners,
|
||||||
|
// Shadowborn Apostle, Dragon's Approach, Seven Dwarves, etc.) override the
|
||||||
|
// singleton/copy-limit rule via oracle text ("a deck can have any number
|
||||||
|
// of cards named..."). Detecting this needs oracle_text on CardRef, which
|
||||||
|
// isn't wired through yet — known limitation, not blocking this feature.
|
||||||
|
|
||||||
|
if (COMMANDER_FORMATS.includes(deck.format)) {
|
||||||
|
// Brawl (Scryfall's `brawl` legality key = paper/Historic Brawl) is
|
||||||
|
// structurally identical to Commander — 100 cards incl. commander,
|
||||||
|
// singleton, commander color-identity restriction — differing only in
|
||||||
|
// which legal/banned card pool applies. Both formats share this
|
||||||
|
// rule-set and differ only in which legalities bit (deck.format itself,
|
||||||
|
// "Commander" or "Brawl") is consulted for the banned/restricted check
|
||||||
|
// below.
|
||||||
|
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
|
||||||
|
if (totalCards !== COMMANDER_DECK_SIZE) {
|
||||||
|
reasons.push(
|
||||||
|
`Deck has ${totalCards} cards; ${deck.format} decks need exactly ${COMMANDER_DECK_SIZE} (including the commander).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commanders = cards.filter((e) => e.is_commander);
|
||||||
|
if (commanders.length === 0) {
|
||||||
|
reasons.push(`No commander is set.`);
|
||||||
|
} else if (commanders.length > 1) {
|
||||||
|
reasons.push(`More than one card is marked as commander.`);
|
||||||
|
}
|
||||||
|
const commander = commanders[0];
|
||||||
|
|
||||||
|
for (const e of cards) {
|
||||||
|
if (e.is_commander) continue;
|
||||||
|
if (!isBasicLand(e) && e.quantity > 1) {
|
||||||
|
reasons.push(
|
||||||
|
`${e.name} has ${e.quantity} copies; ${deck.format} decks allow only 1 (except basic lands).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commander) {
|
||||||
|
for (const e of cards) {
|
||||||
|
if (e.is_commander) continue;
|
||||||
|
const outsideIdentity = e.color_identity & ~commander.color_identity;
|
||||||
|
if (outsideIdentity !== 0) {
|
||||||
|
reasons.push(`${e.name}'s color identity is outside the commander's color identity.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of cards) {
|
||||||
|
const status = legalStatusFor(e.legalities, deck.format);
|
||||||
|
if (status === "banned") {
|
||||||
|
reasons.push(`${e.name} is banned in ${deck.format}.`);
|
||||||
|
} else if (status === "restricted" && e.quantity > 1) {
|
||||||
|
reasons.push(
|
||||||
|
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 60-card constructed formats: Standard, Pioneer, Modern, Legacy,
|
||||||
|
// Vintage, Pauper. No commander concept — is_commander is ignored here.
|
||||||
|
const totalCards = cards.reduce((sum, e) => sum + e.quantity, 0);
|
||||||
|
if (totalCards < CONSTRUCTED_MIN_DECK_SIZE) {
|
||||||
|
reasons.push(
|
||||||
|
`Deck has ${totalCards} cards; ${deck.format} decks need at least ${CONSTRUCTED_MIN_DECK_SIZE}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of cards) {
|
||||||
|
if (!isBasicLand(e) && e.quantity > CONSTRUCTED_MAX_COPIES) {
|
||||||
|
reasons.push(
|
||||||
|
`${e.name} has ${e.quantity} copies; ${deck.format} allows at most ${CONSTRUCTED_MAX_COPIES} (except basic lands).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of cards) {
|
||||||
|
const status = legalStatusFor(e.legalities, deck.format);
|
||||||
|
if (status === "banned") {
|
||||||
|
reasons.push(`${e.name} is banned in ${deck.format}.`);
|
||||||
|
} else if (status === "restricted" && e.quantity > 1) {
|
||||||
|
// In practice this only ever fires for Vintage among the 8 tracked
|
||||||
|
// formats, since Vintage is the only format with a restricted list —
|
||||||
|
// but the check is generic/future-proof rather than Vintage-special-cased.
|
||||||
|
reasons.push(
|
||||||
|
`${e.name} is restricted in ${deck.format} (max 1 copy), deck has ${e.quantity}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: reasons.length ? "illegal" : "legal", reasons };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deck editor. A deck's mainboard 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; "Combine sections" merges these two into one
|
||||||
|
* list. The maybeboard is always shown as its own separate section. Deck-level
|
||||||
|
* format + commander metadata sit at the top.
|
||||||
*/
|
*/
|
||||||
export function DeckView() {
|
export function DeckView() {
|
||||||
const [decks, setDecks] = useState<Deck[]>([]);
|
const [decks, setDecks] = useState<Deck[]>([]);
|
||||||
@@ -207,6 +335,7 @@ export function DeckView() {
|
|||||||
const [contents, setContents] = useState<DeckEntry[]>([]);
|
const [contents, setContents] = useState<DeckEntry[]>([]);
|
||||||
const [view, setView] = useViewMode("deck-view");
|
const [view, setView] = useViewMode("deck-view");
|
||||||
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
const [groupBy, setGroupBy] = usePersistedState<GroupMode>("deck-group", "none");
|
||||||
|
const [combineSections, setCombineSections] = usePersistedState("deck-combine-sections", false);
|
||||||
const [dialog, setDialog] = useState<DeckDialog>(null);
|
const [dialog, setDialog] = useState<DeckDialog>(null);
|
||||||
const [dialogError, setDialogError] = useState<string | null>(null);
|
const [dialogError, setDialogError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -225,6 +354,10 @@ export function DeckView() {
|
|||||||
const [bulkBusy, setBulkBusy] = useState(false);
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [handOpen, setHandOpen] = useState(false);
|
||||||
|
const [hand, setHand] = useState<DeckEntry[]>([]);
|
||||||
|
const [libraryStack, setLibraryStack] = useState<DeckEntry[]>([]);
|
||||||
|
|
||||||
const deck = decks.find((d) => d.id === selected) ?? null;
|
const deck = decks.find((d) => d.id === selected) ?? null;
|
||||||
|
|
||||||
const loadDecks = useCallback(async () => {
|
const loadDecks = useCallback(async () => {
|
||||||
@@ -251,6 +384,9 @@ export function DeckView() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadContents();
|
loadContents();
|
||||||
setSelectedCards(new Set());
|
setSelectedCards(new Set());
|
||||||
|
setHandOpen(false);
|
||||||
|
setHand([]);
|
||||||
|
setLibraryStack([]);
|
||||||
}, [loadContents]);
|
}, [loadContents]);
|
||||||
|
|
||||||
// Cancel any in-flight removal timers on unmount so they don't fire against
|
// Cancel any in-flight removal timers on unmount so they don't fire against
|
||||||
@@ -310,6 +446,39 @@ export function DeckView() {
|
|||||||
setSelected(d.id);
|
setSelected(d.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportDeck(d: Deck) {
|
||||||
|
const path = await save({
|
||||||
|
title: "Export deck",
|
||||||
|
defaultPath: `${d.name}.txt`,
|
||||||
|
filters: [{ name: "Decklist", extensions: ["txt"] }],
|
||||||
|
});
|
||||||
|
if (typeof path === "string") await api.exportDeck(d.id, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickImportFile() {
|
||||||
|
const path = await open({
|
||||||
|
title: "Import deck",
|
||||||
|
filters: [{ name: "Decklist", extensions: ["txt"] }],
|
||||||
|
});
|
||||||
|
if (typeof path !== "string") return;
|
||||||
|
const base = path.split(/[/\\]/).pop() ?? "Imported deck";
|
||||||
|
const suggestedName = base.replace(/\.txt$/i, "");
|
||||||
|
setDialog({ kind: "import-name", suggestedName, path });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmImportName(name: string) {
|
||||||
|
if (dialog?.kind !== "import-name") return;
|
||||||
|
try {
|
||||||
|
const result = await api.importDeck(dialog.path, name);
|
||||||
|
setDialog({ kind: "import-result", result });
|
||||||
|
setDialogError(null);
|
||||||
|
await loadDecks();
|
||||||
|
setSelected(result.deck.id);
|
||||||
|
} catch (err) {
|
||||||
|
setDialogError(friendlyDeckError(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addToDeck(card: CardRef) {
|
async function addToDeck(card: CardRef) {
|
||||||
if (selected === null) return;
|
if (selected === null) return;
|
||||||
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
const existing = contents.find((e) => e.oracle_id === card.oracle_id);
|
||||||
@@ -345,6 +514,35 @@ export function DeckView() {
|
|||||||
await loadContents();
|
await loadContents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every mainboard copy (commander/maybeboard excluded), one entry per
|
||||||
|
* physical copy so a card at quantity N is N times as likely to be drawn,
|
||||||
|
* shuffled in place (Fisher-Yates). */
|
||||||
|
function shuffledLibrary(): DeckEntry[] {
|
||||||
|
const pool: DeckEntry[] = [];
|
||||||
|
for (const e of contents) {
|
||||||
|
if (e.is_commander || e.is_maybeboard) continue;
|
||||||
|
for (let i = 0; i < e.quantity; i++) pool.push(e);
|
||||||
|
}
|
||||||
|
for (let i = pool.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawNewHand() {
|
||||||
|
const shuffled = shuffledLibrary();
|
||||||
|
setHand(shuffled.slice(0, STARTING_HAND_SIZE));
|
||||||
|
setLibraryStack(shuffled.slice(STARTING_HAND_SIZE));
|
||||||
|
setHandOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawOneMore() {
|
||||||
|
if (libraryStack.length === 0) return;
|
||||||
|
setHand((h) => [...h, libraryStack[0]]);
|
||||||
|
setLibraryStack((stack) => stack.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
async function setQty(oracleId: string, qty: number) {
|
async function setQty(oracleId: string, qty: number) {
|
||||||
if (selected === null) return;
|
if (selected === null) return;
|
||||||
const clamped = Math.max(0, qty);
|
const clamped = Math.max(0, qty);
|
||||||
@@ -478,6 +676,7 @@ export function DeckView() {
|
|||||||
const maybeboard = contents.filter((e) => e.is_maybeboard);
|
const maybeboard = contents.filter((e) => e.is_maybeboard);
|
||||||
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
|
const commanderEntry = contents.find((e) => e.oracle_id === deck?.commander_oracle_id);
|
||||||
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
const deckStats = useMemo(() => computeDeckStats(contents), [contents]);
|
||||||
|
const deckLegality = useMemo(() => computeDeckLegality(deck, contents), [deck, contents]);
|
||||||
|
|
||||||
function deckTiles(entries: DeckEntry[]) {
|
function deckTiles(entries: DeckEntry[]) {
|
||||||
return (
|
return (
|
||||||
@@ -506,13 +705,10 @@ export function DeckView() {
|
|||||||
pending={pendingRemoval.has(e.oracle_id)}
|
pending={pendingRemoval.has(e.oracle_id)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
footer={
|
cornerBottomLeft={
|
||||||
<>
|
<IconButton overlay {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
||||||
{e.is_commander && <span className="tile-badge">★ commander</span>}
|
|
||||||
<span className="spacer" />
|
|
||||||
<IconButton {...boardToggleProps(e)} onClick={() => setMaybeboard(e, !e.is_maybeboard)} />
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
|
footer={e.is_commander ? <span className="tile-badge">★ commander</span> : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -580,7 +776,7 @@ export function DeckView() {
|
|||||||
return groupCards(entries, groupBy).map((g) => (
|
return groupCards(entries, groupBy).map((g) => (
|
||||||
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
|
<div key={g.label || "all"} style={{ marginBottom: g.label ? 14 : 0 }}>
|
||||||
{g.label && (
|
{g.label && (
|
||||||
<h4>
|
<h4 className="group-header">
|
||||||
{g.label} <span className="badge">{g.cards.length}</span>
|
{g.label} <span className="badge">{g.cards.length}</span>
|
||||||
</h4>
|
</h4>
|
||||||
)}
|
)}
|
||||||
@@ -592,7 +788,10 @@ export function DeckView() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", gap: 20 }}>
|
<div style={{ display: "flex", gap: 20 }}>
|
||||||
<div style={{ width: 240, flexShrink: 0 }}>
|
<div style={{ width: 240, flexShrink: 0 }}>
|
||||||
<h2>Decks</h2>
|
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||||
|
<h2>Decks</h2>
|
||||||
|
<IconButton icon={Icons.upload} label="Import deck…" onClick={pickImportFile} />
|
||||||
|
</div>
|
||||||
<div className="row" style={{ marginBottom: 10 }}>
|
<div className="row" style={{ marginBottom: 10 }}>
|
||||||
<input
|
<input
|
||||||
value={newName}
|
value={newName}
|
||||||
@@ -662,46 +861,57 @@ export function DeckView() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||||
<div className="row" style={{ gap: 14 }}>
|
<h2>{deck.name}</h2>
|
||||||
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards">
|
<div className="row" style={{ gap: 8 }}>
|
||||||
<input
|
<button onClick={() => exportDeck(deck)}>
|
||||||
ref={selectAllRef}
|
{Icons.download}
|
||||||
type="checkbox"
|
Export deck
|
||||||
checked={contents.length > 0 && selectedCards.size === contents.length}
|
</button>
|
||||||
onChange={toggleSelectAllCards}
|
<button
|
||||||
disabled={contents.length === 0}
|
onClick={drawNewHand}
|
||||||
/>
|
disabled={!contents.some((e) => !e.is_commander && !e.is_maybeboard)}
|
||||||
<span className="muted" style={{ fontSize: 12 }}>
|
>
|
||||||
Select all
|
{Icons.cards}
|
||||||
</span>
|
Sample hand
|
||||||
</label>
|
|
||||||
<h2>{deck.name}</h2>
|
|
||||||
</div>
|
|
||||||
<div className="row" style={{ gap: 16 }}>
|
|
||||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
|
||||||
<ViewToggle mode={view} onChange={setView} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedCards.size > 0 && (
|
|
||||||
<div className="bulk-bar">
|
|
||||||
<span className="bulk-bar-count">{selectedCards.size} selected</span>
|
|
||||||
<div className="bulk-bar-actions">
|
|
||||||
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
|
|
||||||
Remove from deck
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="spacer" />
|
|
||||||
<button onClick={() => setSelectedCards(new Set())} disabled={bulkBusy}>
|
|
||||||
Clear
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
{COMMANDER_FORMATS.includes(deck.format) && commanderEntry && (
|
||||||
<CommanderHero card={commanderEntry} onClear={clearCommander} />
|
<CommanderHero card={commanderEntry} onClear={clearCommander} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
{!commanderEntry && <span className="muted">No commander set yet.</span>}
|
||||||
|
<CardSearchBox
|
||||||
|
placeholder={
|
||||||
|
commanderEntry
|
||||||
|
? "Search to change the commander…"
|
||||||
|
: "Search to set the commander…"
|
||||||
|
}
|
||||||
|
value={cmdQuery}
|
||||||
|
onChange={setCmdQuery}
|
||||||
|
results={cmdResults}
|
||||||
|
onPick={setCommander}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{contents.length > 0 && (
|
{contents.length > 0 && (
|
||||||
<div className="deck-stats">
|
<div className="deck-stats">
|
||||||
<div className="deck-stats-head">Deck analysis</div>
|
<div className="deck-stats-head">Deck analysis</div>
|
||||||
@@ -737,64 +947,131 @@ export function DeckView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Deck metadata: format + (for Commander/Brawl) the commander card. */}
|
{deck.format !== "Other" && (
|
||||||
<div className="deck-meta">
|
<div className={`legality-tile ${deckLegality.status}`}>
|
||||||
<label className="row" style={{ gap: 6 }}>
|
<div className="legality-tile-head">
|
||||||
<span className="muted">Format</span>
|
<span className="legality-tile-icon">
|
||||||
<select value={deck.format} onChange={(e) => changeFormat(e.target.value)}>
|
{deckLegality.status === "legal" ? "✓" : deckLegality.status === "illegal" ? "✗" : "—"}
|
||||||
{FORMATS.map((f) => (
|
</span>
|
||||||
<option key={f} value={f}>
|
<div className="legality-tile-info">
|
||||||
{f || "—"}
|
<span className="legality-tile-status">
|
||||||
</option>
|
{deckLegality.status === "legal"
|
||||||
))}
|
? "Legal"
|
||||||
</select>
|
: deckLegality.status === "illegal"
|
||||||
</label>
|
? "Illegal"
|
||||||
|
: "Not checked"}
|
||||||
{COMMANDER_FORMATS.includes(deck.format) && (
|
</span>
|
||||||
<div className="commander-box">
|
<span className="legality-tile-detail">
|
||||||
{!commanderEntry && <span className="muted">No commander set yet.</span>}
|
{deckLegality.status === "legal"
|
||||||
<CardSearchBox
|
? `Meets all ${deck.format} deck-building rules.`
|
||||||
placeholder={
|
: deckLegality.status === "illegal"
|
||||||
commanderEntry
|
? `${deckLegality.reasons.length} issue${deckLegality.reasons.length === 1 ? "" : "s"} found:`
|
||||||
? "Search to change the commander…"
|
: deck.format
|
||||||
: "Search to set the commander…"
|
? `Legality isn't checked for "${deck.format}".`
|
||||||
}
|
: "Set a format to check legality."}
|
||||||
value={cmdQuery}
|
</span>
|
||||||
onChange={setCmdQuery}
|
</div>
|
||||||
results={cmdResults}
|
|
||||||
onPick={setCommander}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{deckLegality.status === "illegal" && (
|
||||||
|
<ul className="legality-tile-reasons">
|
||||||
|
{deckLegality.reasons.map((r, i) => (
|
||||||
|
<li key={i}>{r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<hr className="deck-section-divider" />
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: 16 }}>
|
||||||
|
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Select all cards">
|
||||||
|
<input
|
||||||
|
ref={selectAllRef}
|
||||||
|
type="checkbox"
|
||||||
|
checked={contents.length > 0 && selectedCards.size === contents.length}
|
||||||
|
onChange={toggleSelectAllCards}
|
||||||
|
disabled={contents.length === 0}
|
||||||
|
/>
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
|
Select all
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="row" style={{ gap: 6, cursor: "pointer" }} title="Combine owned/unowned into one section">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={combineSections}
|
||||||
|
onChange={(e) => setCombineSections(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
|
Combine sections
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<span className="spacer" />
|
||||||
|
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||||
|
<ViewToggle mode={view} onChange={setView} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section style={{ marginTop: 18 }}>
|
{selectedCards.size > 0 && (
|
||||||
<h3>
|
<div className="bulk-bar">
|
||||||
In your library <span className="badge">{owned.length}</span>
|
<span className="bulk-bar-count">{selectedCards.size} selected</span>
|
||||||
</h3>
|
<div className="bulk-bar-actions">
|
||||||
<CardSearchBox
|
<button className="danger" onClick={bulkRemoveFromDeck} disabled={bulkBusy}>
|
||||||
placeholder="Search your library to add owned cards…"
|
Remove from deck
|
||||||
value={libQuery}
|
</button>
|
||||||
onChange={setLibQuery}
|
</div>
|
||||||
results={libResults}
|
<span className="spacer" />
|
||||||
onPick={addToDeck}
|
<button onClick={() => setSelectedCards(new Set())} disabled={bulkBusy}>
|
||||||
/>
|
Clear
|
||||||
{cardsGroup(owned)}
|
</button>
|
||||||
</section>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<section style={{ marginTop: 26 }}>
|
{combineSections ? (
|
||||||
<h3>
|
<section style={{ marginTop: 10 }}>
|
||||||
Not owned <span className="badge">{unowned.length}</span>
|
<h3>
|
||||||
</h3>
|
All cards <span className="badge">{owned.length + unowned.length}</span>
|
||||||
<CardSearchBox
|
</h3>
|
||||||
placeholder="Search all cards to add ones you don't own…"
|
<CardSearchBox
|
||||||
value={anyQuery}
|
placeholder="Search all cards to add to your deck…"
|
||||||
onChange={setAnyQuery}
|
value={anyQuery}
|
||||||
results={anyResults}
|
onChange={setAnyQuery}
|
||||||
onPick={addToDeck}
|
results={anyResults}
|
||||||
/>
|
onPick={addToDeck}
|
||||||
{cardsGroup(unowned)}
|
/>
|
||||||
</section>
|
{cardsGroup([...owned, ...unowned])}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section style={{ marginTop: 10 }}>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<section style={{ marginTop: 26 }}>
|
<section style={{ marginTop: 26 }}>
|
||||||
<h3>
|
<h3>
|
||||||
@@ -848,6 +1125,38 @@ export function DeckView() {
|
|||||||
onCancel={() => setDialog(null)}
|
onCancel={() => setDialog(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{dialog?.kind === "import-name" && (
|
||||||
|
<PromptDialog
|
||||||
|
title="Import deck"
|
||||||
|
label="Deck name"
|
||||||
|
defaultValue={dialog.suggestedName}
|
||||||
|
confirmLabel="Import"
|
||||||
|
error={dialogError}
|
||||||
|
onConfirm={confirmImportName}
|
||||||
|
onCancel={() => setDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dialog?.kind === "import-result" && (
|
||||||
|
<InfoDialog
|
||||||
|
title="Deck imported"
|
||||||
|
message={
|
||||||
|
dialog.result.unresolved.length > 0
|
||||||
|
? `Imported "${dialog.result.deck.name}". ${dialog.result.unresolved.length} card name${dialog.result.unresolved.length === 1 ? "" : "s"} couldn't be matched:`
|
||||||
|
: `Imported "${dialog.result.deck.name}" — every card matched.`
|
||||||
|
}
|
||||||
|
items={dialog.result.unresolved}
|
||||||
|
onClose={() => setDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{handOpen && (
|
||||||
|
<SampleHandModal
|
||||||
|
hand={hand}
|
||||||
|
libraryCount={libraryStack.length}
|
||||||
|
onDrawOne={drawOneMore}
|
||||||
|
onNewHand={drawNewHand}
|
||||||
|
onClose={() => setHandOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter } from "../api";
|
import { api, Deck, LibraryCard, LibraryFacets, LibraryFilter, LibraryImportResult } from "../api";
|
||||||
import {
|
import {
|
||||||
CardThumb,
|
CardThumb,
|
||||||
CardTile,
|
CardTile,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
ColorToggles,
|
ColorToggles,
|
||||||
GroupBySelect,
|
GroupBySelect,
|
||||||
GroupMode,
|
GroupMode,
|
||||||
|
InfoDialog,
|
||||||
LocationField,
|
LocationField,
|
||||||
PromptDialog,
|
PromptDialog,
|
||||||
QtyStepper,
|
QtyStepper,
|
||||||
@@ -49,9 +51,11 @@ export function LibraryBrowser() {
|
|||||||
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [bulkDeckId, setBulkDeckId] = useState<number | "">("");
|
const [bulkDeckId, setBulkDeckId] = useState<number | "">("");
|
||||||
|
const [bulkToMaybeboard, setBulkToMaybeboard] = useState(false);
|
||||||
const [bulkDelta, setBulkDelta] = useState(1);
|
const [bulkDelta, setBulkDelta] = useState(1);
|
||||||
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||||
const [bulkBusy, setBulkBusy] = useState(false);
|
const [bulkBusy, setBulkBusy] = useState(false);
|
||||||
|
const [importResult, setImportResult] = useState<LibraryImportResult | null>(null);
|
||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -172,7 +176,8 @@ export function LibraryBrowser() {
|
|||||||
* owned count (e.g. select a handful of basic lands, "Add All" to put
|
* owned count (e.g. select a handful of basic lands, "Add All" to put
|
||||||
* every copy you own into the deck at once) — overwrites the quantity for
|
* every copy you own into the deck at once) — overwrites the quantity for
|
||||||
* cards already in the deck rather than skipping them, so it doubles as a
|
* cards already in the deck rather than skipping them, so it doubles as a
|
||||||
* bulk "fix the land count" tool.
|
* bulk "fix the land count" tool. `bulkToMaybeboard` sends them to the
|
||||||
|
* deck's maybeboard instead of the mainboard.
|
||||||
*/
|
*/
|
||||||
async function bulkAddToDeck(mode: "one" | "all") {
|
async function bulkAddToDeck(mode: "one" | "all") {
|
||||||
if (bulkDeckId === "") return;
|
if (bulkDeckId === "") return;
|
||||||
@@ -183,7 +188,13 @@ export function LibraryBrowser() {
|
|||||||
const targets = rows.filter((r) => selected.has(r.oracle_id));
|
const targets = rows.filter((r) => selected.has(r.oracle_id));
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
targets.map((r) =>
|
targets.map((r) =>
|
||||||
api.setDeckCard(deck.id, r.oracle_id, mode === "all" ? r.quantity_owned : 1, false),
|
api.setDeckCard(
|
||||||
|
deck.id,
|
||||||
|
r.oracle_id,
|
||||||
|
mode === "all" ? r.quantity_owned : 1,
|
||||||
|
false,
|
||||||
|
bulkToMaybeboard,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
setRefreshKey((k) => k + 1);
|
setRefreshKey((k) => k + 1);
|
||||||
@@ -212,6 +223,28 @@ export function LibraryBrowser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exportSelected() {
|
||||||
|
const targets = rows.filter((r) => selected.has(r.oracle_id)).map((r) => r.oracle_id);
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
const path = await save({
|
||||||
|
title: "Export library cards",
|
||||||
|
defaultPath: "library-export.txt",
|
||||||
|
filters: [{ name: "Decklist", extensions: ["txt"] }],
|
||||||
|
});
|
||||||
|
if (typeof path === "string") await api.exportLibraryCards(targets, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickImportFile() {
|
||||||
|
const path = await open({
|
||||||
|
title: "Import library cards",
|
||||||
|
filters: [{ name: "Decklist", extensions: ["txt"] }],
|
||||||
|
});
|
||||||
|
if (typeof path !== "string") return;
|
||||||
|
const result = await api.importLibraryCards(path);
|
||||||
|
setImportResult(result);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
|
||||||
const deckOrLocation = (c: LibraryCard) =>
|
const deckOrLocation = (c: LibraryCard) =>
|
||||||
c.decks.length === 0 ? (
|
c.decks.length === 0 ? (
|
||||||
<LocationField
|
<LocationField
|
||||||
@@ -342,6 +375,7 @@ export function LibraryBrowser() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ gap: 16 }}>
|
<div className="row" style={{ gap: 16 }}>
|
||||||
|
<button onClick={pickImportFile}>Import cards…</button>
|
||||||
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
<GroupBySelect value={groupBy} onChange={setGroupBy} />
|
||||||
<ViewToggle mode={view} onChange={setView} />
|
<ViewToggle mode={view} onChange={setView} />
|
||||||
</div>
|
</div>
|
||||||
@@ -378,6 +412,17 @@ export function LibraryBrowser() {
|
|||||||
>
|
>
|
||||||
Add All
|
Add All
|
||||||
</button>
|
</button>
|
||||||
|
<label className="row" style={{ gap: 4, cursor: "pointer" }} title="Send to the maybeboard instead of the mainboard">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bulkToMaybeboard}
|
||||||
|
onChange={(e) => setBulkToMaybeboard(e.target.checked)}
|
||||||
|
disabled={bulkBusy}
|
||||||
|
/>
|
||||||
|
<span className="muted" style={{ fontSize: 12 }}>
|
||||||
|
Maybeboard
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => setLocationDialogOpen(true)} disabled={bulkBusy}>
|
<button onClick={() => setLocationDialogOpen(true)} disabled={bulkBusy}>
|
||||||
Set location…
|
Set location…
|
||||||
@@ -405,6 +450,13 @@ export function LibraryBrowser() {
|
|||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={exportSelected}
|
||||||
|
disabled={bulkBusy}
|
||||||
|
title="Export the selected cards to a decklist file"
|
||||||
|
>
|
||||||
|
Export selected
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
|
<button onClick={() => setSelected(new Set())} disabled={bulkBusy}>
|
||||||
@@ -424,6 +476,19 @@ export function LibraryBrowser() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{importResult && (
|
||||||
|
<InfoDialog
|
||||||
|
title="Cards imported"
|
||||||
|
message={
|
||||||
|
importResult.unresolved.length > 0
|
||||||
|
? `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"}. ${importResult.unresolved.length} name${importResult.unresolved.length === 1 ? "" : "s"} couldn't be matched:`
|
||||||
|
: `Added ${importResult.added.length} card${importResult.added.length === 1 ? "" : "s"} to your library.`
|
||||||
|
}
|
||||||
|
items={importResult.unresolved}
|
||||||
|
onClose={() => setImportResult(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="filters">
|
<div className="filters">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Name contains</label>
|
<label>Name contains</label>
|
||||||
@@ -516,7 +581,7 @@ export function LibraryBrowser() {
|
|||||||
groups.map((g) => (
|
groups.map((g) => (
|
||||||
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
|
<div key={g.label || "all"} style={{ marginBottom: g.label ? 20 : 0 }}>
|
||||||
{g.label && (
|
{g.label && (
|
||||||
<h3>
|
<h3 className="group-header">
|
||||||
{g.label} <span className="badge">{g.cards.length}</span>
|
{g.label} <span className="badge">{g.cards.length}</span>
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user