Deck builder: tag cards within a deck
Cards in a deck can carry free-form tags ("to remove", "ramp", …), set
from the card preview or, for several cards at once, from the bulk bar's
Tag… dialog. Tags belong to the card in that deck: they survive
maybeboard moves and quantity changes, go when the card leaves the deck,
and are copied when the deck is duplicated.
The list view gets a Tags column whose chips filter to their tag, tiles
show tags as footer badges, and the advanced filter popup gets a Tags
field (any / all, or untagged only). Select all now selects only the
cards shown, so a filtered view can be acted on as a whole.
Tags sync, which needs library document format 5; libraries without tags
keep writing their old version. Deploy the sync server before releasing
apps that write tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ const LIBRARY_MIGRATIONS: &[&str] = &[
|
||||
include_str!("sql/library/0006_printings.sql"),
|
||||
include_str!("sql/library/0007_deck_cover.sql"),
|
||||
include_str!("sql/library/0008_deck_status.sql"),
|
||||
include_str!("sql/library/0009_deck_card_tags.sql"),
|
||||
];
|
||||
|
||||
/// The attached alias under which the reference db is available at runtime.
|
||||
|
||||
@@ -65,7 +65,7 @@ impl Catalog {
|
||||
}
|
||||
|
||||
/// Duplicate a deck under a new name: same format/commander/notes/cover, plus a
|
||||
/// copy of every deck_cards row (quantity, flags, chosen printing). Fails if
|
||||
/// copy of every deck_cards row (quantity, flags, chosen printing, tags). Fails if
|
||||
/// `new_name` is already taken. The copy starts as an idea: it's a new list,
|
||||
/// not a second set of physical cards.
|
||||
pub fn duplicate_deck(&self, id: i64, new_name: &str) -> Result<Deck> {
|
||||
@@ -81,8 +81,8 @@ impl Catalog {
|
||||
)?;
|
||||
let new_id = self.conn.last_insert_rowid();
|
||||
self.conn.execute(
|
||||
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id)
|
||||
SELECT ?1, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id
|
||||
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id, tags)
|
||||
SELECT ?1, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id, tags
|
||||
FROM deck_cards WHERE deck_id = ?2",
|
||||
params![new_id, id],
|
||||
)?;
|
||||
@@ -160,7 +160,7 @@ impl Catalog {
|
||||
oc.produced_mana, oc.produces_colorless,
|
||||
oc.legalities, {price}, dc.is_maybeboard, {horizontal}, dc.preferred_scryfall_id,
|
||||
oc.oracle_text, dc.deck_id, dc.quantity_pulled, COALESCE(lc.location, ''),
|
||||
{copy_limit}
|
||||
{copy_limit}, dc.tags
|
||||
FROM deck_cards dc
|
||||
JOIN {REF_DB}.oracle_cards oc ON oc.oracle_id = dc.oracle_id
|
||||
LEFT JOIN library_cards lc ON lc.oracle_id = dc.oracle_id
|
||||
@@ -209,6 +209,7 @@ impl Catalog {
|
||||
quantity_spare: 0,
|
||||
quantity_in_box: 0,
|
||||
claimed_by: Vec::new(),
|
||||
tags: parse_tags(&r.get::<_, String>(23)?),
|
||||
};
|
||||
Ok((r.get(19)?, entry))
|
||||
})?
|
||||
@@ -230,6 +231,51 @@ impl Catalog {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Add and remove tags on deck cards, in one transaction: for each slot,
|
||||
/// `remove` goes first, then `add`. Tags are normalised (see
|
||||
/// [`normalize_tag`]) and compared ignoring case, so adding "Ramp" to a
|
||||
/// card tagged "ramp" changes nothing. Cards not in the deck are skipped.
|
||||
pub fn set_deck_card_tags(
|
||||
&self,
|
||||
deck_id: i64,
|
||||
oracle_ids: &[&str],
|
||||
add: &[&str],
|
||||
remove: &[&str],
|
||||
) -> Result<()> {
|
||||
let add: Vec<String> = add.iter().filter_map(|t| normalize_tag(t)).collect();
|
||||
let remove: Vec<String> = remove
|
||||
.iter()
|
||||
.filter_map(|t| normalize_tag(t))
|
||||
.map(|t| t.to_lowercase())
|
||||
.collect();
|
||||
let tx = self.nested_tx()?;
|
||||
for oid in oracle_ids {
|
||||
let current: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT tags FROM deck_cards WHERE deck_id = ?1 AND oracle_id = ?2",
|
||||
params![deck_id, oid],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
let Some(current) = current else { continue };
|
||||
let mut tags = parse_tags(¤t);
|
||||
tags.retain(|t| !remove.contains(&t.to_lowercase()));
|
||||
for t in &add {
|
||||
let lower = t.to_lowercase();
|
||||
if !tags.iter().any(|have| have.to_lowercase() == lower) {
|
||||
tags.push(t.clone());
|
||||
}
|
||||
}
|
||||
self.conn.execute(
|
||||
"UPDATE deck_cards SET tags = ?1 WHERE deck_id = ?2 AND oracle_id = ?3",
|
||||
params![tags_json(&tags), deck_id, oid],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every building/built deck's main-board claim on each card, keyed by
|
||||
/// oracle id.
|
||||
fn physical_holdings(&self) -> Result<HashMap<String, Vec<DeckHolding>>> {
|
||||
@@ -480,6 +526,28 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest a tag may be, in characters.
|
||||
const MAX_TAG_LEN: usize = 40;
|
||||
|
||||
/// Tidy a tag as the user typed it: trimmed, inner whitespace collapsed to
|
||||
/// single spaces, cut to [`MAX_TAG_LEN`] characters. `None` if nothing's left.
|
||||
pub fn normalize_tag(tag: &str) -> Option<String> {
|
||||
let tag: String = tag.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let tag: String = tag.chars().take(MAX_TAG_LEN).collect::<String>().trim_end().to_string();
|
||||
(!tag.is_empty()).then_some(tag)
|
||||
}
|
||||
|
||||
/// A `deck_cards.tags` value (a JSON array of strings) as a list. Anything
|
||||
/// unreadable counts as no tags.
|
||||
pub(crate) fn parse_tags(json: &str) -> Vec<String> {
|
||||
serde_json::from_str(json).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The `deck_cards.tags` value for a list of tags.
|
||||
pub(crate) fn tags_json<'a>(tags: impl IntoIterator<Item = &'a String>) -> String {
|
||||
serde_json::to_string(&tags.into_iter().collect::<Vec<_>>()).expect("strings always serialise")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::test_support;
|
||||
@@ -978,4 +1046,52 @@ mod tests {
|
||||
assert_eq!(limit("llanowar-elves"), Some(0));
|
||||
assert_eq!(limit("sol-ring"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_are_added_removed_normalised_and_deduped() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let d = cat.create_deck("Tags", None).unwrap();
|
||||
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
|
||||
cat.set_deck_card(d.id, "lightning-bolt", 1, false, false).unwrap();
|
||||
let tags = |oid: &str| {
|
||||
cat.deck_contents(d.id).unwrap().into_iter().find(|e| e.card.oracle_id == oid).unwrap().tags
|
||||
};
|
||||
|
||||
cat.set_deck_card_tags(d.id, &["sol-ring", "lightning-bolt"], &[" To remove ", "Ramp", ""], &[])
|
||||
.unwrap();
|
||||
assert_eq!(tags("sol-ring"), vec!["To remove", "Ramp"]);
|
||||
assert_eq!(tags("lightning-bolt"), vec!["To remove", "Ramp"]);
|
||||
|
||||
// Matching ignores case; the first casing written is kept.
|
||||
cat.set_deck_card_tags(d.id, &["sol-ring"], &["ramp", "RAMP"], &["to REMOVE"]).unwrap();
|
||||
assert_eq!(tags("sol-ring"), vec!["Ramp"]);
|
||||
assert_eq!(tags("lightning-bolt"), vec!["To remove", "Ramp"]);
|
||||
|
||||
// Long tags are cut; cards not in the deck are skipped.
|
||||
cat.set_deck_card_tags(d.id, &["sol-ring", "serra-angel"], &[&"x".repeat(60)], &[]).unwrap();
|
||||
assert_eq!(tags("sol-ring")[1].chars().count(), 40);
|
||||
assert!(cat.deck_contents(d.id).unwrap().iter().all(|e| e.card.oracle_id != "serra-angel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_stay_with_the_slot() {
|
||||
let fx = test_support::make();
|
||||
let cat = &fx.catalog;
|
||||
let d = cat.create_deck("Tags", None).unwrap();
|
||||
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
|
||||
cat.set_deck_card_tags(d.id, &["sol-ring"], &["swap?"], &[]).unwrap();
|
||||
let tags = || cat.deck_contents(d.id).unwrap().first().map(|e| e.tags.clone());
|
||||
|
||||
cat.set_deck_card(d.id, "sol-ring", 1, false, true).unwrap();
|
||||
cat.set_deck_card(d.id, "sol-ring", 3, false, false).unwrap();
|
||||
assert_eq!(tags(), Some(vec!["swap?".to_string()]), "maybeboard moves and quantity keep tags");
|
||||
|
||||
let copy = cat.duplicate_deck(d.id, "Copy").unwrap();
|
||||
assert_eq!(cat.deck_contents(copy.id).unwrap()[0].tags, vec!["swap?"]);
|
||||
|
||||
cat.set_deck_card(d.id, "sol-ring", 0, false, false).unwrap();
|
||||
cat.set_deck_card(d.id, "sol-ring", 1, false, false).unwrap();
|
||||
assert_eq!(tags(), Some(vec![]), "removing the card drops its tags");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +250,10 @@ pub struct DeckEntry {
|
||||
/// [`crate::roles::card_roles`]. Empty when nothing matches.
|
||||
#[serde(default)]
|
||||
pub roles: Vec<String>,
|
||||
/// The user's own tags on this card in this deck ("to remove", …), each
|
||||
/// normalised by [`crate::decks::normalize_tag`].
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// A building/built deck's claim on a card's copies.
|
||||
|
||||
5
crates/mtgc-core/src/sql/library/0009_deck_card_tags.sql
Normal file
5
crates/mtgc-core/src/sql/library/0009_deck_card_tags.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- v9 — user-defined tags on a deck card ("to remove", "ramp", …), as a JSON
|
||||
-- array of strings. On the slot rather than a join table so they go with it:
|
||||
-- removing the card from the deck drops them, while maybeboard moves and
|
||||
-- quantity changes leave them alone.
|
||||
ALTER TABLE deck_cards ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';
|
||||
@@ -57,9 +57,9 @@ pub mod remote;
|
||||
/// dropped printing data would publish its loss as a deletion on the next
|
||||
/// sync, so documents that carry any are marked 2 and older builds refuse
|
||||
/// them — while libraries that don't use printings stay readable everywhere.
|
||||
/// Version 3 does the same for deck covers, and 4 for deck statuses and
|
||||
/// pulled copies.
|
||||
pub const DOC_VERSION: u32 = 4;
|
||||
/// Version 3 does the same for deck covers, 4 for deck statuses and pulled
|
||||
/// copies, and 5 for deck card tags.
|
||||
pub const DOC_VERSION: u32 = 5;
|
||||
|
||||
/// The format of a document with no printing data in it.
|
||||
const BASE_VERSION: u32 = 1;
|
||||
@@ -90,6 +90,9 @@ pub struct DeckCardEntry {
|
||||
/// Copies physically in the deck (see migration 0008).
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub pulled: i64,
|
||||
/// The user's tags on this card in this deck (see migration 0009).
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub tags: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// The [`CardEntry::printings`] key for a copy group: `"<scryfall_id>/<finish>"`.
|
||||
@@ -185,10 +188,13 @@ impl LibraryDoc {
|
||||
self.cards.is_empty() && self.decks.is_empty()
|
||||
}
|
||||
|
||||
/// The lowest format that can hold this document: 4 once any deck has a
|
||||
/// status or pulled copies, 3 once any deck has a cover, 2 once it carries
|
||||
/// The lowest format that can hold this document: 5 once any deck card
|
||||
/// has tags, 4 once any deck has a status or pulled copies, 3 once any deck has a cover, 2 once it carries
|
||||
/// any printing data, else 1. See [`DOC_VERSION`].
|
||||
pub fn required_version(&self) -> u32 {
|
||||
if self.decks.values().flat_map(|d| d.cards.values()).any(|c| !c.tags.is_empty()) {
|
||||
return 5;
|
||||
}
|
||||
if self
|
||||
.decks
|
||||
.values()
|
||||
@@ -543,7 +549,7 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT deck_id, oracle_id, quantity, is_commander, is_maybeboard, preferred_scryfall_id,
|
||||
quantity_pulled
|
||||
quantity_pulled, tags
|
||||
FROM deck_cards",
|
||||
)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
@@ -561,6 +567,7 @@ pub fn read_doc(conn: &Connection) -> Result<LibraryDoc> {
|
||||
maybeboard: row.get::<_, i64>(4)? != 0,
|
||||
printing: row.get(5)?,
|
||||
pulled: row.get(6)?,
|
||||
tags: crate::decks::parse_tags(&row.get::<_, String>(7)?).into_iter().collect(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -651,14 +658,15 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> {
|
||||
for (oracle_id, card) in &deck.cards {
|
||||
tx.execute(
|
||||
"INSERT INTO deck_cards(deck_id, oracle_id, quantity, is_commander, is_maybeboard,
|
||||
preferred_scryfall_id, quantity_pulled)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
preferred_scryfall_id, quantity_pulled, tags)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(deck_id, oracle_id) DO UPDATE SET
|
||||
quantity = excluded.quantity,
|
||||
is_commander = excluded.is_commander,
|
||||
is_maybeboard = excluded.is_maybeboard,
|
||||
preferred_scryfall_id = excluded.preferred_scryfall_id,
|
||||
quantity_pulled = excluded.quantity_pulled",
|
||||
quantity_pulled = excluded.quantity_pulled,
|
||||
tags = excluded.tags",
|
||||
rusqlite::params![
|
||||
deck_id,
|
||||
oracle_id,
|
||||
@@ -666,7 +674,8 @@ pub fn apply_doc(conn: &Connection, doc: &LibraryDoc) -> Result<()> {
|
||||
card.commander as i64,
|
||||
card.maybeboard as i64,
|
||||
card.printing,
|
||||
card.pulled
|
||||
card.pulled,
|
||||
crate::decks::tags_json(&card.tags)
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -1021,6 +1030,19 @@ mod tests {
|
||||
assert_eq!(back, merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_5_is_used_only_once_a_deck_card_has_tags() {
|
||||
let mut d = doc(&[("a", 1)], &[("u1", "Fub", &[("a", 1)])]);
|
||||
d.decks.get_mut("u1").unwrap().status = DeckStatus::Built;
|
||||
assert_eq!(d.required_version(), 4);
|
||||
assert!(!d.to_json().unwrap().contains("tags"));
|
||||
|
||||
d.decks.get_mut("u1").unwrap().cards.get_mut("a").unwrap().tags.insert("ramp".into());
|
||||
assert_eq!(d.required_version(), 5);
|
||||
let back = LibraryDoc::from_json(&d.to_json().unwrap()).unwrap();
|
||||
assert_eq!(back, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deck_status_merges_like_other_deck_fields() {
|
||||
let base = doc(&[], &[("u1", "Fub", &[])]);
|
||||
@@ -1072,8 +1094,11 @@ mod tests {
|
||||
c.set_location("card-a", "Box 1").unwrap();
|
||||
c.set_deck_status(deck.id, DeckStatus::Building, false).unwrap();
|
||||
c.set_deck_card_pulled(deck.id, "card-a", 1).unwrap();
|
||||
c.set_deck_card_tags(deck.id, &["card-a"], &["ramp", "to remove"], &[]).unwrap();
|
||||
|
||||
let before = read_doc(&c.conn).unwrap();
|
||||
assert_eq!(before.version, 5);
|
||||
assert_eq!(before.decks.values().next().unwrap().cards["card-a"].tags.len(), 2);
|
||||
assert_eq!(before.decks.values().next().unwrap().cover.as_deref(), Some("card-a"));
|
||||
assert_eq!(before.decks.values().next().unwrap().status, DeckStatus::Building);
|
||||
assert_eq!(before.decks.values().next().unwrap().cards["card-a"].pulled, 1);
|
||||
|
||||
@@ -463,6 +463,20 @@ async fn only_valid_libraries_are_stored() {
|
||||
assert_eq!(s.put_library(&t, Some("0"), json!(["nope"])).await.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_library_with_deck_card_tags_is_stored() {
|
||||
let s = Server::local();
|
||||
let t = s.sign_in("alice", "correct horse battery").await;
|
||||
let doc = json!({
|
||||
"version": 5,
|
||||
"cards": {},
|
||||
"decks": { "u1": { "name": "Fub", "cards": { "a": { "quantity": 1, "tags": ["ramp"] } } } }
|
||||
});
|
||||
assert_eq!(s.put_library(&t, Some("0"), doc).await.status(), StatusCode::OK);
|
||||
let got = body_json(s.get_library(&t).await).await;
|
||||
assert_eq!(got["doc"]["decks"]["u1"]["cards"]["a"]["tags"], json!(["ramp"]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn each_user_has_their_own_library() {
|
||||
let s = Server::local();
|
||||
|
||||
Reference in New Issue
Block a user