Compare commits

..

31 Commits

Author SHA1 Message Date
5d21571561 Added mount command 2025-10-22 20:27:37 +01:00
821d301de3 Added sorting for cards 2025-08-21 03:30:04 +01:00
6b5034c544 Fixed when you a disambiguated name in first
Still should probably clean up the return values.

Also should look into how to exit early from the script
2025-08-21 03:09:17 +01:00
43a75c0b92 Added some dbg
Also found a bug
2025-08-21 02:46:20 +01:00
130287caa7 Added a wrapper script...
... to the wrapp script around the ... binary

For the purposes of allow me to add a nice shortcut for my window manager
2025-08-21 02:30:31 +01:00
3df07d7622 Added some more explanation around the script 2025-08-21 02:11:55 +01:00
fd358f5d3f Misspelled script stuff seems to be working 2025-08-21 01:56:54 +01:00
f1beae9198 Updated README and starting on more complex rofi script 2025-08-21 01:45:50 +01:00
4dde40b72e updated README 2025-08-21 00:46:44 +01:00
a21520b5e5 Added README 2025-08-21 00:44:34 +01:00
af1ac9b5fa Updated update and added an update script
The --update flag accepts the file no rather than goes to download.

Simpler. Better.

Don't need the entire download.rs file now
2025-08-21 00:18:26 +01:00
9f9a0b1fb7 Sorted results by closeness 2025-08-20 23:28:10 +01:00
3e1b89312a String closeness seems alright
Might be worth sorting by relevance
2025-08-20 23:23:35 +01:00
3c78637809 Separated previously used function 2025-08-20 22:37:55 +01:00
e40a64579b Scryfall-type search is working 2025-08-20 22:30:57 +01:00
e966a22707 Added some return codes for later usage
Fixed a quoting bug
2025-08-20 21:35:53 +01:00
3f7cd6353a Moved string matching to sqlite
Probably (definitely more efficient)
2025-08-20 21:08:12 +01:00
6ff0204189 Added comment to possibly explain why rofi -e is exiting early
When double clicking
2025-08-19 23:45:49 +01:00
9a8c971d73 Made script mroe POSIX compliant
Remove the "here-string" (<<<) with a "here-document" (<< EOF ... EOF)
2025-08-19 23:26:50 +01:00
b6664492fa Improved Null in db
Fixed the rofi error window from instantly being removed with
a double click - there's probably a better way like using some
IGNORE_FIRST_CLICK_FOR_10ms or something
2025-08-19 23:21:42 +01:00
bcf68c8332 Added some rofi stuff for easier & quicker interaction
Looking actually pretty okay!
2025-08-19 23:01:32 +01:00
3f4af21a93 Search with uppercase should work
I made the search string also lowercase
2025-08-19 22:12:18 +01:00
98af8885a6 Added searching by lower or card name
Also added help strings
2025-08-19 22:02:18 +01:00
9f03e3e11f Seaching card by name works 2025-08-17 01:08:27 +01:00
9a9f42bc1e Changing how to use textdifference thing
I think it'd be better to use text difference if a substring isn't found.

I realised afterwards that I think this is how Scryfall does it anyway.
2025-08-17 00:34:03 +01:00
6558a31619 Matching substrings works
Want to try also using something like this for finding spelling mistakes etc.
https://github.com/life4/textdistance.rs

Going to have to try to do some combination though to ensure exact substring
matches, even when missing the latter half, still work well. Maybe... I dunno
will have to try.
2025-08-16 21:38:34 +01:00
72fa35d41a Got all cards out from db 2025-08-16 03:59:24 +01:00
ff4c58113f Creating database seems alright
Unsure really whether the data is in there properly - but there
is data in there!
2025-08-16 03:41:51 +01:00
da121940da Updated test files and changes in scryfall db
Also removed PromoType - don't need that right now
2025-08-16 02:41:17 +01:00
9c2d9c1fb7 Fixed matching the BulkType
The serde to_string thing has "" around the strings.
2025-08-16 02:21:43 +01:00
1462401787 Added a utils file
Wanted to separate the higher level config folder stuff from just db module

Because the download module will also want to find cache
2025-08-15 02:00:50 +01:00
15 changed files with 768 additions and 147 deletions

View File

@@ -0,0 +1 @@
sudo mount -t cifs -o uid=arthurr,username=arthurr //truenas.local/Music /home/arthurr/TruenasMusic/

View File

@@ -9,10 +9,12 @@ edition = "2021"
chrono = { version = "0.4.39", features = ["serde"] }
clap = { version = "4.5.42", features = ["derive"] }
closestmatch = "0.1.2"
deunicode = "1.6.2"
dir_spec = "0.2.0"
rusqlite = "0.37.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.138"
sqlite = "0.37.0"
tempfile = "3.20.0"
textdistance = "1.1.1"
ureq = { version = "3.0.12", features = ["json"] }
uuid = { version = "1.12.1", features = ["v4", "serde"] }

113
scryfall_deser/README.md Normal file
View File

@@ -0,0 +1,113 @@
# Quick Magic Finder
A quick way to search up Magic the Gathering (TM) cards for Linux (maybe MacOS? Don't have one, so haven't tried).
Currently, no images are displayed - so don't go into this expecting that.
## The Components
This repo has 2 main parts to it:
* The `magic_finder` rust code, which does the "heavy lifting" of updating a database, searching through it for cards, and finding close names (kind of)
* The supporting scripts which use [`rofi`](https://github.com/davatorium/rofi)
`magic_finder` can be used without the rofi parts if you wanted a command for loading showing basic mtg card info from the CLI.
The `rofi` part is so that I can quickly and easily get the card info I want. Basically just adds a very simple and easy GUI to the `magic_finder` part. I've written 2 wrapper scripts to enable this.
## Requirements
### Magic Finder
`magic_finder` is written in rust. Check the Cargo.toml file for more specific requirements. I was compiling with rustc version 1.88, but I would not at all be surprised if it worked on rust from year(s) ago.
### `rofi`
The rofi scripts require a version over (I think) 1.7.6. The scripts require the ability to set the `command` setting on the `filebrowser` option in rofi. From what I can tell, this was introduced in 1.7.6.
Very annoyingly, the Ubuntu repos do *not* have this version. They have an earlier version. So, to use this, you'll need to get a more recent version yourself. I compiled and installed myself. See the end of this README for how I did that.
## Usage
**TODO**
## Installation, First Usage, and Updating
### Installation
I am sorry in advance, this is a bit of a pain becuase of my lack of knowledge on how to properly "package" scripts alongside a rust binary.
**TODO - don't forget that I need to install sqlite3-lib/dev/something - or maybe I add the feature flag "bundled"**
*TODO - don't forget about adding the shortcut to the overall wrapper script*
## Before you use this
## Update
Basically the same as the "Before you use this" secion. Go to the [Scryfall Bulk Download](https://scryfall.com/docs/api/bulk-data) page and get the Oracle Cards download.
Then run the `update_with_rofi.sh` script, locate the downloaded file, and it should Just Work (TM). If not, try updating this repo. If it still doesn't work, log a ticket. It's probably going to something with Scryfall updating their schema that I haven't accounted for. Alternatively, run `COMMAND --update <path to file>` where `<path to file>` is the full path to where you downloaded the file.
NOTE: Updating *will* delete the previous db - that shouldn't be a problem though, because you shouldn't use that unless you really know what you're doing.
## Why this exists
I like watch Magic the Gathering (TM) videos, expecially while coding, working, writing, whatever. Often, I don't know what card they're talking about. They'll often say the card name (sometimes a nickname - this tool doesn't help with that), and show it on the screen briefly (or in a tiny/obscured view), and I'll miss what it actually does. When this happens, I need to open a tab on my browser, go to [Scryfall](scryfall.com), type in the name, (sometimes) click the specific card, and the view it. This takes 2-3 page loads, changing my active window and just a bit of a pain.
This tool, especially using `rofi` enables me to hit `Ctrl+M`, type in the card name, navigate to the card (if needed) with my keyboard, and display the card. No browser, no HTTP, lower context switch, displayed right there, and goes away when I press anything else.
The idea is it's just easier and quicker than my normal process.
## How I Installed `rofi`
If you're smarter than me, just follow the official docs: https://github.com/davatorium/rofi/blob/next/INSTALL.md and don't bother reading this.
I am entirely unfamiliar with `meson` and `ninja` (I'm more a `Makefile` kinda guy - haven't done any `C` properly in >10 years), so here's what I did. This is for Ubutntu - you will need to do something different for download the dependencies (you can see them in the INSTALL.md file referenced above).
Of note below, I'm installing this into my `$HOME/bin` directory. Change that part if you want to install somewhere else. Make sure to install it somewhere in your `$PATH` though!
Clone the repo and move into it
```
git clone --recursive https://github.com/davatorium/rofi
cd rofi
```
Install the deps
```
sudo apt build-dep rofi
sudo apt install meson
sudo apt install libxcb-keysyms1-dev libxcb-keysyms1 # I suspect only one of these is needed - not sure which
```
Setup and build (not sure why I put the prefix in here... you'll see below I still copy+pasted the bin)
```
meson setup build --prefix $HOME/bin -Dwayland=disabled -Dxcb=enabled
ninja -C build -v
```
Fingers crossed that all compiled and stuff... then copy the bin
```
cp build/rofi ~/bin
```
## FIXME, TODO & Features That Could be Good
* Remove the non-card cards. Examples I've come across are:
** Planes: Black Lotus Lounge
** Art Cards: https://scryfall.com/card/altr/15/%C3%A9owyn-fearless-knight-%C3%A9owyn-fearless-knight?utm_source=api
* Allow exiting the script early (i.e. I hit CTRL+g just exits everything)
* Misspelled cards, if only 1 hit that makes sense, could just work
* Display the actual card image (probably won't do this)
* Some kind of auto-magic direct link between the return codes set out in `main.r`s and the `rofi` scripts. Currently I need to manually make sure they're the same between the `rust` code and the `sh` code.
I'm guessing would involve cargo build scripts (or just a find+replace?)
* Add some classic nicknames (might be difficult to find them all). examples include:
** Bob - Dark Confidant
** AK - Accumulated Knowledge
** find more here: https://mtg.wiki/page/List_of_Magic_slang/Card_nicknames
## Thanks
This project really is just 95% based on Scryfall. They're amazing. I don't know how or why they exist, but I think they're basically the best Magic the Gathering (TM) resource online.
Of course `rofi` for providing the quick, simple, low-weight, and well documented tool. Particularly the quickness and low-weight which made it really possible. The alternative was opening terminal windows, or using TKinter or something... surely not worth it.
Thanks to all the amazing `rust` packages I use.

View File

@@ -0,0 +1,4 @@
#!/bin/sh
SEARCH_STRING=$(rofi -l 0 -dmenu)
/home/arthurr/code/mini_projects/scryfall_deser/scripts/search_with_rofi_with_args.sh $SEARCH_STRING

View File

@@ -0,0 +1,74 @@
#!/bin/sh
# Note to self in this... The whitespace seemed to fuck the ifs up. Not sure why.
# This is why it's all flat and ugly
CARDS=$(/home/arthurr/code/mini_projects/scryfall_deser/target/debug/scryfall_deser $@)
RETURN=$?
echo $RETURN
#######################
## Exact card found - just print the card
#######################
if [ $RETURN -eq 200 ]; then
rofi -e "$CARDS"
fi
#######################
## Cards to select from
#######################
if [ $RETURN -eq 0 ]; then
SELECTION=$(rofi -dmenu -i << EOF
$CARDS
EOF
)
CARD_OUTPUT=$(/home/arthurr/code/mini_projects/scryfall_deser/target/debug/scryfall_deser --exact $SELECTION)
# If you double check the first rofi selection it seems to prevent the error window from popping up
# I think this is because it registers the second click as a click outside the window which exits
# the rofi -e message
sleep 0.05
rofi -e "$CARD_OUTPUT"
fi
##########################
## Not even one card that matched - try a close string
##########################
if [ $RETURN -eq 105 ]; then
# TODO do something different with no matching string at all - perhaps even a different ExitCode?
SELECTION=$(rofi -dmenu -p "Did you mean?" -i << EOF
$CARDS
EOF
)
CARDS=$(/home/arthurr/code/mini_projects/scryfall_deser/target/debug/scryfall_deser $SELECTION)
SELECTION=$(rofi -dmenu -i << EOF
$CARDS
EOF
)
CARD_OUTPUT=$(/home/arthurr/code/mini_projects/scryfall_deser/target/debug/scryfall_deser --exact $SELECTION)
sleep 0.05
rofi -e "$CARD_OUTPUT"
fi
###############################
## No seach string input at all
###############################
if [ $RETURN -eq 101 ]; then
rofi -e "No search string found"
fi

View File

@@ -0,0 +1,9 @@
#!/bin/sh
SCRYFALL_BULK=$(rofi -modi filebrowser -show filebrowser -filebrowser-command printf)
echo "$SCRYFALL_BULK"
/home/arthurr/code/mini_projects/scryfall_deser/target/debug/scryfall_deser --update "$SCRYFALL_BULK"
# TODO - check return value
rofi -e "Should be updated"

View File

@@ -1,23 +1,13 @@
use dir_spec::Dir;
use sqlite;
use deunicode::deunicode;
use rusqlite;
use std::cmp::Ordering;
use std::fmt;
use std::fs;
use std::path::PathBuf;
const DATA_FOLDER: &str = "scryfall";
const SQLITE_FILENAME: &str = "scryfall_db.sqlite3";
fn get_local_data_folder() -> PathBuf {
let cache_folder = Dir::data_home();
match cache_folder {
None => {
panic!("Can't find a cache folder - really don't know what the problem is sorry");
}
Some(mut f) => {
f.push(DATA_FOLDER);
f
}
}
}
use super::deser::ScryfallCard;
use super::utils::get_local_cache_folder;
use super::utils::{create_cache_folder, get_local_data_folder, SQLITE_FILENAME};
fn get_local_data_sqlite_file() -> PathBuf {
let mut folder = get_local_data_folder();
@@ -25,51 +15,275 @@ fn get_local_data_sqlite_file() -> PathBuf {
folder
}
// NOTE: this should be idempotent - creating a dir always is... right?
pub fn create_cache_folder() {
let f = get_local_data_folder();
let ret = fs::create_dir(&f);
match ret {
Ok(_) => (),
Err(e) => {
if e.raw_os_error().unwrap() == 17 {
// This is folder already exists - which is fine for us
// TODO probably should use e.kind() for better readability
return;
}
panic!(
"Couldn't create folder within your cache folder: {}. Error is {}",
f.display(),
e
);
pub fn get_all_card_names() -> Vec<String> {
let sqlite_file = get_local_data_sqlite_file();
let conn = rusqlite::Connection::open(sqlite_file).unwrap();
let mut stmt = conn.prepare("SELECT name FROM cards;").unwrap();
let mut rows = stmt.query([]).unwrap();
let mut card_names = Vec::new();
while let Some(row) = rows.next().unwrap() {
card_names.push(row.get(0).unwrap());
}
card_names
}
pub fn get_all_lowercase_card_names() -> Vec<String> {
let sqlite_file = get_local_data_sqlite_file();
let conn = rusqlite::Connection::open(sqlite_file).unwrap();
let mut stmt = conn.prepare("SELECT lowercase_name FROM cards;").unwrap();
let mut rows = stmt.query([]).unwrap();
let mut card_names = Vec::new();
while let Some(row) = rows.next().unwrap() {
card_names.push(row.get(0).unwrap());
}
card_names
}
pub fn get_all_mtg_words() -> Vec<String> {
let sqlite_file = get_local_data_sqlite_file();
let conn = rusqlite::Connection::open(sqlite_file).unwrap();
let mut stmt = conn.prepare("SELECT word FROM mtg_words;").unwrap();
let mut rows = stmt.query([]).unwrap();
let mut card_names = Vec::new();
while let Some(row) = rows.next().unwrap() {
card_names.push(row.get(0).unwrap());
}
card_names
}
// unsure if this should be in this file...
impl fmt::Display for DbCard {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match &self.mana_cost {
Some(mc) => writeln!(f, "{}\t{}", self.name, mc)?,
None => writeln!(f, "{}", self.name)?,
}
writeln!(f, "{}", self.type_line)?;
match &self.oracle_text {
Some(ot) => writeln!(f, "{}", ot)?,
None => (),
}
match &self.power_toughness {
Some(pt) => writeln!(f, "{}", pt)?,
None => (),
}
match &self.loyalty {
Some(l) => writeln!(f, "Starting Loyalty: {}", l)?,
None => (),
}
writeln!(f, "Scryfall URI: {}", self.scryfall_uri)?;
Ok(())
}
}
fn create_db_sql() -> String {
"
impl Ord for DbCard {
fn cmp(&self, other: &Self) -> Ordering {
self.name.cmp(&other.name)
}
}
impl PartialOrd for DbCard {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for DbCard {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
}
}
impl Eq for DbCard {}
#[derive(Debug)]
pub struct DbCard {
pub name: String,
pub lowercase_name: String,
pub type_line: String,
pub oracle_text: Option<String>,
pub power_toughness: Option<String>,
pub loyalty: Option<String>,
pub mana_cost: Option<String>,
pub scryfall_uri: String,
}
pub enum GetNameType {
Name,
LowercaseName,
}
pub fn get_card_by_name(name: &str, name_type: GetNameType) -> Option<DbCard> {
let sqlite_file = get_local_data_sqlite_file();
let conn = rusqlite::Connection::open(sqlite_file).unwrap();
let sql = match name_type {
GetNameType::Name => {
"SELECT name, lowercase_name, type_line, oracle_text, power_toughness, loyalty, mana_cost, scryfall_uri
FROM cards WHERE name = (?1)"
}
GetNameType::LowercaseName => {
"SELECT name, lowercase_name, type_line, oracle_text, power_toughness, loyalty, mana_cost, scryfall_uri
FROM cards WHERE lowercase_name = (?1)"
}
};
dbg!(name);
dbg!(&sql);
let mut stmt = conn.prepare(sql).unwrap();
let mut rows = stmt.query([name]).unwrap();
match rows.next().unwrap() {
Some(row) => Some(DbCard {
name: row.get(0).unwrap(),
lowercase_name: row.get(1).unwrap(),
type_line: row.get(2).unwrap(),
oracle_text: row.get(3).unwrap(),
power_toughness: row.get(4).unwrap(),
loyalty: row.get(5).unwrap(),
mana_cost: row.get(6).unwrap(),
scryfall_uri: row.get(7).unwrap(),
}),
None => None,
}
}
pub fn find_matching_cards_scryfall_style(search_strings: &Vec<String>) -> Vec<DbCard> {
assert!(!search_strings.is_empty());
let sqlite_file = get_local_data_sqlite_file();
let conn = rusqlite::Connection::open(sqlite_file).unwrap();
let mut percentaged_string = Vec::new();
// I know that .clone fixes my problem - I'm not sure why I need to though
for mut search_string in search_strings.clone() {
search_string.push('%');
search_string.insert(0, '%');
percentaged_string.push(search_string);
}
let mut sql: String = "SELECT name, lowercase_name, type_line, oracle_text, power_toughness, loyalty, mana_cost, scryfall_uri
FROM cards WHERE".into();
for i in 0..search_strings.len() {
sql.push_str(&format!(" lowercase_name LIKE (?{}) AND", i + 1));
}
sql.pop();
sql.pop();
sql.pop();
sql.pop();
let mut stmt = conn.prepare(&sql).unwrap();
stmt.query_map(rusqlite::params_from_iter(percentaged_string), |row| {
Ok(DbCard {
name: row.get(0).unwrap(),
lowercase_name: row.get(1).unwrap(),
type_line: row.get(2).unwrap(),
oracle_text: row.get(3).unwrap(),
power_toughness: row.get(4).unwrap(),
loyalty: row.get(5).unwrap(),
mana_cost: row.get(6).unwrap(),
scryfall_uri: row.get(7).unwrap(),
})
})
.unwrap()
.filter_map(|res| res.ok())
.collect()
}
pub fn find_matching_cards(name: &str) -> Vec<DbCard> {
let sqlite_file = get_local_data_sqlite_file();
let conn = rusqlite::Connection::open(sqlite_file).unwrap();
// There must be something better than this - although I don't think it's possible with a str
let mut name = name.to_string();
name.push('%');
name.insert(0, '%');
let mut stmt = conn
.prepare(
"SELECT name, lowercase_name, type_line, oracle_text, power_toughness, loyalty, mana_cost, scryfall_uri
FROM cards WHERE lowercase_name LIKE (?1)",
)
.unwrap();
stmt.query_map([name], |row| {
Ok(DbCard {
name: row.get(0).unwrap(),
lowercase_name: row.get(1).unwrap(),
type_line: row.get(2).unwrap(),
oracle_text: row.get(3).unwrap(),
power_toughness: row.get(4).unwrap(),
loyalty: row.get(5).unwrap(),
mana_cost: row.get(6).unwrap(),
scryfall_uri: row.get(7).unwrap(),
})
})
.unwrap()
.filter_map(|res| res.ok())
.collect()
}
const CREATE_CARDS_TABLE_SQL: &str = "
CREATE TABLE cards (
name TEXT NOT NULL UNIQUE,
lowercase_name TEXT NOT NULL UNIQUE,
type_line TEXT,
oracle_text TEXT,
power_toughness TEXT,
loyalty INTEGER,
legalities TEXT,
loyalty TEXT,
mana_cost TEXT,
scryfall_uri TEXT NOT NULL UNIQUE
);"
.to_string()
}
)";
const CREATE_MAGIC_WORDS_TABLE_SQL: &str = "
CREATE TABLE mtg_words (
word TEXT NOT NULL UNIQUE
)";
// Will delete your current db
pub fn init_db() -> bool {
create_cache_folder();
let sqlite_file = get_local_data_sqlite_file();
println!("sqlite file location: {}", sqlite_file.display());
// TESTING
println!("cache folder: {}", get_local_cache_folder().display());
let _res = fs::remove_file(&sqlite_file);
// TODO actually check result for whether it was a permissions thing or something
let connection = sqlite::open(sqlite_file).unwrap();
let init_query = create_db_sql();
connection.execute(init_query).unwrap();
let connection = rusqlite::Connection::open(sqlite_file).unwrap();
connection.execute(&CREATE_CARDS_TABLE_SQL, ()).unwrap();
connection
.execute(&CREATE_MAGIC_WORDS_TABLE_SQL, ())
.unwrap();
true
}
pub fn update_db_with_file(file: PathBuf) -> bool {
let ac = fs::read_to_string(file).unwrap();
let ac: Vec<ScryfallCard> = serde_json::from_str(&ac).unwrap();
let sqlite_file = get_local_data_sqlite_file();
let mut conn = rusqlite::Connection::open(sqlite_file).unwrap();
let tx = conn.transaction().unwrap();
for card in ac {
for word in card.name.split_whitespace() {
let word = deunicode(&word.to_lowercase());
let res = tx.execute(
"INSERT INTO mtg_words (word) VALUES (?1)
ON CONFLICT (word) DO NOTHING;",
[word.replace(",", "")],
);
}
let lowercase_name = deunicode(&card.name.to_lowercase());
let power_toughness = match card.power {
Some(p) => Some(format!("{}/{}", p, card.toughness.unwrap())),
None => None,
};
let oracle_text = match card.oracle_text {
Some(ot) => ot,
None => "<No Oracle Text>".to_string(),
};
let loyalty = match card.loyalty {
Some(loy) => Some(loy),
None => None,
};
let mana_cost = match card.mana_cost {
Some(mc) => Some(mc),
None => None,
};
let res = tx.execute(
"INSERT INTO cards (name, lowercase_name, type_line, oracle_text, power_toughness, loyalty, mana_cost, scryfall_uri) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
rusqlite::params![card.name, lowercase_name, card.type_line, oracle_text, power_toughness, loyalty, mana_cost, card.scryfall_uri],
);
}
tx.commit();
true
}

View File

@@ -43,6 +43,7 @@ pub struct ScryfallCard {
pub colours: Option<Vec<Colour>>,
pub edhrec_rank: Option<u64>,
pub defense: Option<String>,
pub game_changer: bool,
pub hand_modifier: Option<String>,
pub keywords: Vec<String>, // Words like "Flying"
pub legalities: FormatLegalities,
@@ -89,7 +90,8 @@ pub struct ScryfallCard {
pub printed_text: Option<String>,
pub printed_type_line: Option<String>,
pub promo: bool,
pub promo_types: Option<Vec<PromoTypes>>,
//pub promo_types: Option<Vec<PromoTypes>>,
pub promo_types: Option<Vec<String>>,
pub purchase_uris: Option<PurchaseUris>,
pub rarity: Rarity,
pub related_uris: Value, // TODO: - list all the URIs? Maybe? Who cares?
@@ -199,7 +201,6 @@ pub struct FormatLegalities {
timeless: Legality,
gladiator: Legality,
pioneer: Legality,
explorer: Legality,
modern: Legality,
legacy: Legality,
pauper: Legality,

View File

@@ -5,7 +5,7 @@ use ureq;
use uuid::Uuid;
#[allow(dead_code)]
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone)]
struct ScryfallBulkData {
pub id: Uuid,
pub uri: String,
@@ -20,7 +20,7 @@ struct ScryfallBulkData {
}
#[allow(dead_code)]
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone)]
struct ScryfallBulk {
pub object: String,
pub has_more: bool,
@@ -56,11 +56,17 @@ pub fn download_latest(
let mut download_uri = String::new();
for scryfall_bulk in bulk_body.data {
if serde_json::to_string(&stype).unwrap() == scryfall_bulk.stype {
if serde_json::to_string(&stype)
.unwrap()
.contains(&scryfall_bulk.stype)
{
download_uri = scryfall_bulk.download_uri;
break;
}
}
assert!(!download_uri.is_empty());
return Ok(());
// Just while testing - don't need to download 150MB every time...
let cards_response = ureq::get(download_uri)
.header("User-Agent", "Arthur's Card Finger Testing v0.1")
.header("Accept", "application/json")

View File

@@ -5,4 +5,11 @@ mod deser;
pub use crate::deser::ScryfallCard;
mod db;
pub use db::init_db;
pub use db::{
find_matching_cards, find_matching_cards_scryfall_style, get_all_card_names,
get_all_lowercase_card_names, get_all_mtg_words, get_card_by_name, init_db,
update_db_with_file, GetNameType,
};
mod utils;
pub use utils::get_local_cache_folder;

View File

@@ -1,25 +1,134 @@
use clap::Parser;
use scryfall_deser::find_matching_cards_scryfall_style;
use scryfall_deser::get_all_mtg_words;
use scryfall_deser::get_card_by_name;
use scryfall_deser::get_local_cache_folder;
use scryfall_deser::init_db;
use scryfall_deser::update_db_with_file;
use scryfall_deser::GetNameType;
use std::process::ExitCode;
use std::process::Termination;
use textdistance::str::damerau_levenshtein;
impl Termination for MtgCardExit {
fn report(self) -> ExitCode {
match self {
MtgCardExit::Success => ExitCode::SUCCESS,
MtgCardExit::EmptySearchString => ExitCode::from(101),
MtgCardExit::NoExactMatchCard => ExitCode::from(102),
MtgCardExit::DidYouMean => ExitCode::from(105),
MtgCardExit::ExactCardFound => ExitCode::from(200),
MtgCardExit::UpdateSuccess => ExitCode::from(201),
}
}
}
enum MtgCardExit {
Success,
UpdateSuccess,
ExactCardFound,
EmptySearchString,
NoExactMatchCard,
DidYouMean,
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
/// Update the local db from given Scryfall bulk download
#[arg(short, long)]
update: bool,
remainder: Vec<String>,
update: Option<String>,
/// Search for the exact string
#[arg(short, long)]
exact: bool,
/// Text to search for card with
search_text: Vec<String>,
}
fn main() {
fn exact_search(search_strings: Vec<String>) -> MtgCardExit {
let search_string = search_strings.join(" ");
let card = get_card_by_name(&search_string, GetNameType::Name);
match card {
None => {
println!("No card found with exact name of {}", search_string);
MtgCardExit::NoExactMatchCard
}
Some(c) => {
println!("{}", c);
MtgCardExit::ExactCardFound
}
}
}
// For use with find_matching_cards
fn _combine_search_strings(search_strings: Vec<String>) -> String {
let mut search_string = String::new();
for card in search_strings {
search_string.push_str(&card.to_lowercase());
search_string.push_str(" ");
}
search_string.pop();
search_string
}
fn main() -> MtgCardExit {
let args = Args::parse();
if args.update {
unimplemented!("Haven't implemented update yet");
if let Some(update) = args.update {
dbg!(&update);
init_db();
let mut path = get_local_cache_folder();
path.push(update);
// FIXME - if you pass a bad file or something, it just deletes the db
update_db_with_file(path);
return MtgCardExit::UpdateSuccess;
}
let card_name = args.remainder;
if card_name.is_empty() {
panic!("You need to put some card text to search");
if args.search_text.is_empty() {
dbg!("You need to put some card text to search");
return MtgCardExit::EmptySearchString;
}
let search_string = card_name.join(" ");
dbg!(search_string);
init_db();
if args.exact {
let res = exact_search(args.search_text);
return res;
}
let mut matching_cards = find_matching_cards_scryfall_style(&args.search_text);
dbg!(&args.search_text);
dbg!(&matching_cards);
if matching_cards.is_empty() {
let mtg_words = get_all_mtg_words();
let mut close_names = Vec::new();
for search_string in args.search_text {
for mtg_card_name in &mtg_words {
let dist = damerau_levenshtein(&search_string, &mtg_card_name);
if dist <= 2 {
close_names.push((dist, mtg_card_name));
}
}
}
close_names.sort_by_key(|k| k.0);
for (_, card) in close_names {
println!("{}", card);
}
return MtgCardExit::DidYouMean;
} else if matching_cards.len() == 1 {
// FIXME - theres a bug in here - try searching Nalf
let card = get_card_by_name(&matching_cards[0].name, GetNameType::Name).unwrap();
println!("{}", card);
// TODO update this to be more meaningful
return MtgCardExit::ExactCardFound;
} else {
matching_cards.sort();
for card in matching_cards {
println!(
"{}",
get_card_by_name(&card.lowercase_name, GetNameType::LowercaseName)
.unwrap()
.name
);
}
// TODO update this to be more meaningful
return MtgCardExit::Success;
}
}

View File

@@ -0,0 +1,80 @@
use dir_spec::Dir;
use std::fs;
use std::path::PathBuf;
pub const LOCAL_FOLDER: &str = "scryfall";
pub const SQLITE_FILENAME: &str = "scryfall_db.sqlite3";
pub fn get_local_data_folder() -> PathBuf {
let data_folder = Dir::data_home();
match data_folder {
None => {
panic!("Can't find a data folder - really don't know what the problem is sorry");
}
Some(mut f) => {
f.push(LOCAL_FOLDER);
f
}
}
}
pub fn get_local_cache_folder() -> PathBuf {
let cache_folder = Dir::cache_home();
match cache_folder {
None => {
panic!("Can't find a cache folder - really don't know what the problem is sorry");
}
Some(mut f) => {
f.push(LOCAL_FOLDER);
f
}
}
}
fn get_local_data_sqlite_file() -> PathBuf {
let mut folder = get_local_data_folder();
folder.push(SQLITE_FILENAME);
folder
}
// NOTE: this should be idempotent - creating a dir always is... right?
pub fn create_data_folder() {
let f = get_local_data_folder();
let ret = fs::create_dir(&f);
match ret {
Ok(_) => (),
Err(e) => {
if e.raw_os_error().unwrap() == 17 {
// 17 = this is folder already exists - which is fine for us
// TODO probably should use e.kind() for better readability
return;
}
panic!(
"Couldn't create folder within your cache folder: {}. Error is {}",
f.display(),
e
);
}
}
}
// NOTE: this should be idempotent - creating a dir always is... right?
pub fn create_cache_folder() {
let f = get_local_cache_folder();
let ret = fs::create_dir(&f);
match ret {
Ok(_) => (),
Err(e) => {
if e.raw_os_error().unwrap() == 17 {
// 17 = this is folder already exists - which is fine for us
// TODO probably should use e.kind() for better readability
return;
}
panic!(
"Couldn't create folder within your cache folder: {}. Error is {}",
f.display(),
e
);
}
}
}

View File

@@ -1,28 +1,32 @@
{
"object": "card",
"id": "f5d24a5b-c950-4fd9-99e6-a4b979d915b3",
"id": "bd8fa327-dd41-4737-8f19-2cf5eb1f7cdd",
"oracle_id": "5089ec1a-f881-4d55-af14-5d996171203b",
"multiverse_ids": [],
"multiverse_ids": [
382866
],
"mtgo_id": 53155,
"mtgo_foil_id": 53156,
"name": "Black Lotus",
"lang": "en",
"released_at": "2023-12-08",
"uri": "https://api.scryfall.com/cards/f5d24a5b-c950-4fd9-99e6-a4b979d915b3",
"scryfall_uri": "https://scryfall.com/card/ovnt/2023NA/black-lotus?utm_source=api",
"released_at": "2014-06-16",
"uri": "https://api.scryfall.com/cards/bd8fa327-dd41-4737-8f19-2cf5eb1f7cdd",
"scryfall_uri": "https://scryfall.com/card/vma/4/black-lotus?utm_source=api",
"layout": "normal",
"highres_image": false,
"image_status": "lowres",
"highres_image": true,
"image_status": "highres_scan",
"image_uris": {
"small": "https://cards.scryfall.io/small/front/f/5/f5d24a5b-c950-4fd9-99e6-a4b979d915b3.jpg?1738105453",
"normal": "https://cards.scryfall.io/normal/front/f/5/f5d24a5b-c950-4fd9-99e6-a4b979d915b3.jpg?1738105453",
"large": "https://cards.scryfall.io/large/front/f/5/f5d24a5b-c950-4fd9-99e6-a4b979d915b3.jpg?1738105453",
"png": "https://cards.scryfall.io/png/front/f/5/f5d24a5b-c950-4fd9-99e6-a4b979d915b3.png?1738105453",
"art_crop": "https://cards.scryfall.io/art_crop/front/f/5/f5d24a5b-c950-4fd9-99e6-a4b979d915b3.jpg?1738105453",
"border_crop": "https://cards.scryfall.io/border_crop/front/f/5/f5d24a5b-c950-4fd9-99e6-a4b979d915b3.jpg?1738105453"
"small": "https://cards.scryfall.io/small/front/b/d/bd8fa327-dd41-4737-8f19-2cf5eb1f7cdd.jpg?1614638838",
"normal": "https://cards.scryfall.io/normal/front/b/d/bd8fa327-dd41-4737-8f19-2cf5eb1f7cdd.jpg?1614638838",
"large": "https://cards.scryfall.io/large/front/b/d/bd8fa327-dd41-4737-8f19-2cf5eb1f7cdd.jpg?1614638838",
"png": "https://cards.scryfall.io/png/front/b/d/bd8fa327-dd41-4737-8f19-2cf5eb1f7cdd.png?1614638838",
"art_crop": "https://cards.scryfall.io/art_crop/front/b/d/bd8fa327-dd41-4737-8f19-2cf5eb1f7cdd.jpg?1614638838",
"border_crop": "https://cards.scryfall.io/border_crop/front/b/d/bd8fa327-dd41-4737-8f19-2cf5eb1f7cdd.jpg?1614638838"
},
"mana_cost": "{0}",
"cmc": 0,
"type_line": "Artifact",
"oracle_text": "{T}, Sacrifice Black Lotus: Add three mana of any one color.",
"oracle_text": "{T}, Sacrifice this artifact: Add three mana of any one color.",
"colors": [],
"color_identity": [],
"keywords": [],
@@ -40,7 +44,6 @@
"timeless": "not_legal",
"gladiator": "not_legal",
"pioneer": "not_legal",
"explorer": "not_legal",
"modern": "not_legal",
"legacy": "banned",
"pauper": "not_legal",
@@ -58,43 +61,44 @@
"predh": "banned"
},
"games": [
"paper"
"mtgo"
],
"reserved": true,
"foil": false,
"game_changer": false,
"foil": true,
"nonfoil": true,
"finishes": [
"nonfoil"
"nonfoil",
"foil"
],
"oversized": true,
"oversized": false,
"promo": false,
"reprint": true,
"variation": false,
"set_id": "c6a6b61b-143a-43f2-b74d-b140f3d93490",
"set": "ovnt",
"set_name": "Vintage Championship",
"set_type": "memorabilia",
"set_uri": "https://api.scryfall.com/sets/c6a6b61b-143a-43f2-b74d-b140f3d93490",
"set_search_uri": "https://api.scryfall.com/cards/search?order=set&q=e%3Aovnt&unique=prints",
"scryfall_set_uri": "https://scryfall.com/sets/ovnt?utm_source=api",
"rulings_uri": "https://api.scryfall.com/cards/f5d24a5b-c950-4fd9-99e6-a4b979d915b3/rulings",
"set_id": "a944551a-73fa-41cd-9159-e8d0e4674403",
"set": "vma",
"set_name": "Vintage Masters",
"set_type": "masters",
"set_uri": "https://api.scryfall.com/sets/a944551a-73fa-41cd-9159-e8d0e4674403",
"set_search_uri": "https://api.scryfall.com/cards/search?order=set&q=e%3Avma&unique=prints",
"scryfall_set_uri": "https://scryfall.com/sets/vma?utm_source=api",
"rulings_uri": "https://api.scryfall.com/cards/bd8fa327-dd41-4737-8f19-2cf5eb1f7cdd/rulings",
"prints_search_uri": "https://api.scryfall.com/cards/search?order=released&q=oracleid%3A5089ec1a-f881-4d55-af14-5d996171203b&unique=prints",
"collector_number": "2023NA",
"digital": false,
"rarity": "special",
"flavor_text": "2023 North America\nVintage Championship",
"collector_number": "4",
"digital": true,
"rarity": "bonus",
"card_back_id": "0aeebaf5-8c7d-4636-9e82-8c27447861f7",
"artist": "Scott M. Fischer",
"artist": "Chris Rahn",
"artist_ids": [
"23b0cf43-3e43-44c6-8329-96446eca5bce"
"7742047e-0f80-4c0f-a530-d07460165e86"
],
"illustration_id": "4bc3f69f-66b6-4a6f-8b55-09df0ea4cb89",
"illustration_id": "da62ded1-bedd-44c6-8950-ca56e691a899",
"border_color": "black",
"frame": "2015",
"security_stamp": "oval",
"full_art": false,
"textless": false,
"booster": false,
"booster": true,
"story_spotlight": false,
"prices": {
"usd": null,
@@ -102,16 +106,17 @@
"usd_etched": null,
"eur": null,
"eur_foil": null,
"tix": null
"tix": "41.98"
},
"related_uris": {
"tcgplayer_infinite_articles": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=infinite&u=https%3A%2F%2Finfinite.tcgplayer.com%2Fsearch%3FcontentMode%3Darticle%26game%3Dmagic%26q%3DBlack%2BLotus",
"tcgplayer_infinite_decks": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=infinite&u=https%3A%2F%2Finfinite.tcgplayer.com%2Fsearch%3FcontentMode%3Ddeck%26game%3Dmagic%26q%3DBlack%2BLotus",
"gatherer": "https://gatherer.wizards.com/Pages/Card/Details.aspx?multiverseid=382866&printed=false",
"tcgplayer_infinite_articles": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=tcgplayer.com%2Fsearch%2Farticles&u=https%3A%2F%2Fwww.tcgplayer.com%2Fsearch%2Farticles%3FproductLineName%3Dmagic%26q%3DBlack%2BLotus",
"tcgplayer_infinite_decks": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=tcgplayer.com%2Fsearch%2Fdecks&u=https%3A%2F%2Fwww.tcgplayer.com%2Fsearch%2Fdecks%3FproductLineName%3Dmagic%26q%3DBlack%2BLotus",
"edhrec": "https://edhrec.com/route/?cc=Black+Lotus"
},
"purchase_uris": {
"tcgplayer": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&u=https%3A%2F%2Fwww.tcgplayer.com%2Fsearch%2Fmagic%2Fproduct%3FproductLineName%3Dmagic%26q%3DBlack%2BLotus%26view%3Dgrid",
"cardmarket": "https://www.cardmarket.com/en/Magic/Products/Search?referrer=scryfall&searchString=Black+Lotus&utm_campaign=card_prices&utm_medium=text&utm_source=scryfall",
"cardhoarder": "https://www.cardhoarder.com/cards?affiliate_id=scryfall&data%5Bsearch%5D=Black+Lotus&ref=card-profile&utm_campaign=affiliate&utm_medium=card&utm_source=scryfall"
"cardhoarder": "https://www.cardhoarder.com/cards/53155?affiliate_id=scryfall&ref=card-profile&utm_campaign=affiliate&utm_medium=card&utm_source=scryfall"
}
}

View File

@@ -43,7 +43,6 @@
"timeless": "not_legal",
"gladiator": "not_legal",
"pioneer": "not_legal",
"explorer": "not_legal",
"modern": "not_legal",
"legacy": "not_legal",
"pauper": "not_legal",
@@ -64,6 +63,7 @@
"paper"
],
"reserved": false,
"game_changer": false,
"foil": true,
"nonfoil": true,
"finishes": [
@@ -100,23 +100,22 @@
"booster": true,
"story_spotlight": false,
"prices": {
"usd": "0.29",
"usd_foil": "16.25",
"usd": "0.31",
"usd_foil": "15.64",
"usd_etched": null,
"eur": "0.29",
"eur_foil": "12.69",
"eur": "0.19",
"eur_foil": "11.09",
"tix": null
},
"related_uris": {
"gatherer": "https://gatherer.wizards.com/Pages/Card/Details.aspx?multiverseid=74257&printed=false",
"tcgplayer_infinite_articles": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=infinite&u=https%3A%2F%2Finfinite.tcgplayer.com%2Fsearch%3FcontentMode%3Darticle%26game%3Dmagic%26q%3DLittle%2BGirl",
"tcgplayer_infinite_decks": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=infinite&u=https%3A%2F%2Finfinite.tcgplayer.com%2Fsearch%3FcontentMode%3Ddeck%26game%3Dmagic%26q%3DLittle%2BGirl",
"tcgplayer_infinite_articles": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=tcgplayer.com%2Fsearch%2Farticles&u=https%3A%2F%2Fwww.tcgplayer.com%2Fsearch%2Farticles%3FproductLineName%3Dmagic%26q%3DLittle%2BGirl",
"tcgplayer_infinite_decks": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=tcgplayer.com%2Fsearch%2Fdecks&u=https%3A%2F%2Fwww.tcgplayer.com%2Fsearch%2Fdecks%3FproductLineName%3Dmagic%26q%3DLittle%2BGirl",
"edhrec": "https://edhrec.com/route/?cc=Little+Girl"
},
"purchase_uris": {
"tcgplayer": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&u=https%3A%2F%2Fwww.tcgplayer.com%2Fproduct%2F37883%3Fpage%3D1",
"cardmarket": "https://www.cardmarket.com/en/Magic/Products/Singles/Unhinged/Little-Girl?referrer=scryfall&utm_campaign=card_prices&utm_medium=text&utm_source=scryfall",
"cardmarket": "https://www.cardmarket.com/en/Magic/Products?idProduct=14779&referrer=scryfall&utm_campaign=card_prices&utm_medium=text&utm_source=scryfall",
"cardhoarder": "https://www.cardhoarder.com/cards?affiliate_id=scryfall&data%5Bsearch%5D=Little+Girl&ref=card-profile&utm_campaign=affiliate&utm_medium=card&utm_source=scryfall"
}
}

View File

@@ -1,28 +1,29 @@
{
"object": "card",
"id": "7b3f0e26-7784-452d-acc8-9f7181e0f7d5",
"id": "248c76d3-b5cb-4582-be17-7cd1d0cb0f58",
"oracle_id": "c1fc5923-c3cd-448a-98d1-c154661c2812",
"multiverse_ids": [
615951
615415
],
"mtgo_id": 109062,
"tcgplayer_id": 495791,
"cardmarket_id": 710527,
"mtgo_id": 109166,
"arena_id": 85078,
"tcgplayer_id": 495610,
"cardmarket_id": 710196,
"name": "Nissa, Resurgent Animist",
"lang": "en",
"released_at": "2023-05-12",
"uri": "https://api.scryfall.com/cards/7b3f0e26-7784-452d-acc8-9f7181e0f7d5",
"scryfall_uri": "https://scryfall.com/card/mat/162/nissa-resurgent-animist?utm_source=api",
"uri": "https://api.scryfall.com/cards/248c76d3-b5cb-4582-be17-7cd1d0cb0f58",
"scryfall_uri": "https://scryfall.com/card/mat/22/nissa-resurgent-animist?utm_source=api",
"layout": "normal",
"highres_image": true,
"image_status": "highres_scan",
"image_uris": {
"small": "https://cards.scryfall.io/small/front/7/b/7b3f0e26-7784-452d-acc8-9f7181e0f7d5.jpg?1684341884",
"normal": "https://cards.scryfall.io/normal/front/7/b/7b3f0e26-7784-452d-acc8-9f7181e0f7d5.jpg?1684341884",
"large": "https://cards.scryfall.io/large/front/7/b/7b3f0e26-7784-452d-acc8-9f7181e0f7d5.jpg?1684341884",
"png": "https://cards.scryfall.io/png/front/7/b/7b3f0e26-7784-452d-acc8-9f7181e0f7d5.png?1684341884",
"art_crop": "https://cards.scryfall.io/art_crop/front/7/b/7b3f0e26-7784-452d-acc8-9f7181e0f7d5.jpg?1684341884",
"border_crop": "https://cards.scryfall.io/border_crop/front/7/b/7b3f0e26-7784-452d-acc8-9f7181e0f7d5.jpg?1684341884"
"small": "https://cards.scryfall.io/small/front/2/4/248c76d3-b5cb-4582-be17-7cd1d0cb0f58.jpg?1684340632",
"normal": "https://cards.scryfall.io/normal/front/2/4/248c76d3-b5cb-4582-be17-7cd1d0cb0f58.jpg?1684340632",
"large": "https://cards.scryfall.io/large/front/2/4/248c76d3-b5cb-4582-be17-7cd1d0cb0f58.jpg?1684340632",
"png": "https://cards.scryfall.io/png/front/2/4/248c76d3-b5cb-4582-be17-7cd1d0cb0f58.png?1684340632",
"art_crop": "https://cards.scryfall.io/art_crop/front/2/4/248c76d3-b5cb-4582-be17-7cd1d0cb0f58.jpg?1684340632",
"border_crop": "https://cards.scryfall.io/border_crop/front/2/4/248c76d3-b5cb-4582-be17-7cd1d0cb0f58.jpg?1684340632"
},
"mana_cost": "{2}{G}",
"cmc": 3,
@@ -47,13 +48,12 @@
"W"
],
"legalities": {
"standard": "legal",
"future": "legal",
"standard": "not_legal",
"future": "not_legal",
"historic": "legal",
"timeless": "legal",
"gladiator": "legal",
"pioneer": "legal",
"explorer": "legal",
"modern": "legal",
"legacy": "legal",
"pauper": "not_legal",
@@ -61,7 +61,7 @@
"penny": "not_legal",
"commander": "legal",
"oathbreaker": "legal",
"standardbrawl": "legal",
"standardbrawl": "not_legal",
"brawl": "legal",
"alchemy": "not_legal",
"paupercommander": "not_legal",
@@ -76,6 +76,7 @@
"mtgo"
],
"reserved": false,
"game_changer": false,
"foil": true,
"nonfoil": true,
"finishes": [
@@ -93,9 +94,9 @@
"set_uri": "https://api.scryfall.com/sets/6727e43d-31b6-45b0-ae05-7a811ba72f70",
"set_search_uri": "https://api.scryfall.com/cards/search?order=set&q=e%3Amat&unique=prints",
"scryfall_set_uri": "https://scryfall.com/sets/mat?utm_source=api",
"rulings_uri": "https://api.scryfall.com/cards/7b3f0e26-7784-452d-acc8-9f7181e0f7d5/rulings",
"rulings_uri": "https://api.scryfall.com/cards/248c76d3-b5cb-4582-be17-7cd1d0cb0f58/rulings",
"prints_search_uri": "https://api.scryfall.com/cards/search?order=released&q=oracleid%3Ac1fc5923-c3cd-448a-98d1-c154661c2812&unique=prints",
"collector_number": "162",
"collector_number": "22",
"digital": false,
"rarity": "mythic",
"watermark": "desparked",
@@ -108,35 +109,31 @@
"border_color": "black",
"frame": "2015",
"frame_effects": [
"legendary",
"extendedart"
"legendary"
],
"security_stamp": "oval",
"full_art": false,
"textless": false,
"booster": false,
"booster": true,
"story_spotlight": false,
"promo_types": [
"boosterfun"
],
"edhrec_rank": 2163,
"edhrec_rank": 2064,
"prices": {
"usd": "24.96",
"usd_foil": "32.13",
"usd": "16.18",
"usd_foil": "16.73",
"usd_etched": null,
"eur": "27.94",
"eur_foil": "35.15",
"tix": "0.99"
"eur": "22.93",
"eur_foil": "22.72",
"tix": "0.17"
},
"related_uris": {
"gatherer": "https://gatherer.wizards.com/Pages/Card/Details.aspx?multiverseid=615951&printed=false",
"tcgplayer_infinite_articles": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=infinite&u=https%3A%2F%2Finfinite.tcgplayer.com%2Fsearch%3FcontentMode%3Darticle%26game%3Dmagic%26q%3DNissa%252C%2BResurgent%2BAnimist",
"tcgplayer_infinite_decks": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=infinite&u=https%3A%2F%2Finfinite.tcgplayer.com%2Fsearch%3FcontentMode%3Ddeck%26game%3Dmagic%26q%3DNissa%252C%2BResurgent%2BAnimist",
"gatherer": "https://gatherer.wizards.com/Pages/Card/Details.aspx?multiverseid=615415&printed=false",
"tcgplayer_infinite_articles": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=tcgplayer.com%2Fsearch%2Farticles&u=https%3A%2F%2Fwww.tcgplayer.com%2Fsearch%2Farticles%3FproductLineName%3Dmagic%26q%3DNissa%252C%2BResurgent%2BAnimist",
"tcgplayer_infinite_decks": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&trafcat=tcgplayer.com%2Fsearch%2Fdecks&u=https%3A%2F%2Fwww.tcgplayer.com%2Fsearch%2Fdecks%3FproductLineName%3Dmagic%26q%3DNissa%252C%2BResurgent%2BAnimist",
"edhrec": "https://edhrec.com/route/?cc=Nissa%2C+Resurgent+Animist"
},
"purchase_uris": {
"tcgplayer": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&u=https%3A%2F%2Fwww.tcgplayer.com%2Fproduct%2F495791%3Fpage%3D1",
"cardmarket": "https://www.cardmarket.com/en/Magic/Products/Singles/MoM-TA-E/Nissa-Resurgent-Animist-V3?referrer=scryfall&utm_campaign=card_prices&utm_medium=text&utm_source=scryfall",
"cardhoarder": "https://www.cardhoarder.com/cards/109062?affiliate_id=scryfall&ref=card-profile&utm_campaign=affiliate&utm_medium=card&utm_source=scryfall"
"tcgplayer": "https://partner.tcgplayer.com/c/4931599/1830156/21018?subId1=api&u=https%3A%2F%2Fwww.tcgplayer.com%2Fproduct%2F495610%3Fpage%3D1",
"cardmarket": "https://www.cardmarket.com/en/Magic/Products?idProduct=710196&referrer=scryfall&utm_campaign=card_prices&utm_medium=text&utm_source=scryfall",
"cardhoarder": "https://www.cardhoarder.com/cards/109166?affiliate_id=scryfall&ref=card-profile&utm_campaign=affiliate&utm_medium=card&utm_source=scryfall"
}
}