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:
2026-10-29 14:42:40 +00:00
parent ef4a0d740d
commit 70c89a5644
13 changed files with 557 additions and 35 deletions

View File

@@ -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.

View File

@@ -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(&current);
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");
}
}

View File

@@ -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.

View 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 '[]';

View File

@@ -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);