diff --git a/Cargo.lock b/Cargo.lock index a460f25..fe359a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,137 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atk" version = "0.18.2" @@ -154,6 +285,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -343,6 +487,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.1" @@ -738,6 +891,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -765,6 +945,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -898,6 +1098,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1270,6 +1483,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1549,6 +1768,25 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1825,6 +2063,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-opener", ] [[package]] @@ -2153,12 +2392,33 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2184,6 +2444,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2272,6 +2538,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2317,6 +2594,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2950,6 +3241,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3106,6 +3407,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3367,6 +3679,28 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -3784,9 +4118,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -3836,6 +4182,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4790,6 +5147,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.52" @@ -4875,3 +5293,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow 1.0.3", +] diff --git a/crates/mtgc-core/src/http.rs b/crates/mtgc-core/src/http.rs new file mode 100644 index 0000000..a63b633 --- /dev/null +++ b/crates/mtgc-core/src/http.rs @@ -0,0 +1,53 @@ +//! Shared HTTP plumbing. +//! +//! One agent configuration for every outbound request, so timeouts and the +//! User-Agent can't drift apart between the bulk ingester and anything else +//! that reaches the network. + +use std::io::{BufReader, Read}; + +use crate::error::Result; + +/// Identifies this app to the services it calls. Scryfall's API guidelines ask +/// for a descriptive User-Agent so they can tell clients apart. +pub(crate) const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)"; + +/// A default-configured agent. +/// +/// Without explicit timeouts a stalled socket hangs the caller forever with no +/// way out, and mobile networks stall like this routinely. +/// +/// `timeout_read` is per read call, not for the whole transfer, so a +/// multi-hundred-megabyte download is fine as long as it keeps making progress. +pub(crate) fn agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_connect(std::time::Duration::from_secs(30)) + .timeout_read(std::time::Duration::from_secs(60)) + .build() +} + +/// GET `url` as a buffered stream, for responses too large to hold in memory. +pub(crate) fn get_reader(url: &str) -> Result { + let resp = agent().get(url).set("User-Agent", USER_AGENT).call()?; + Ok(BufReader::with_capacity(1 << 20, resp.into_reader())) +} + +/// GET `url` as text. For pages small enough to buffer — `ureq` caps this at +/// 10MB, which is far above anything we ask for. +pub(crate) fn get_string(url: &str) -> Result { + Ok(agent() + .get(url) + .set("User-Agent", USER_AGENT) + .call()? + .into_string()?) +} + +/// GET `url` and deserialize the JSON body. +pub(crate) fn get_json(url: &str) -> Result { + Ok(agent() + .get(url) + .set("User-Agent", USER_AGENT) + .set("Accept", "application/json") + .call()? + .into_json()?) +} diff --git a/crates/mtgc-core/src/ingest.rs b/crates/mtgc-core/src/ingest.rs index cd6d5fe..948576c 100644 --- a/crates/mtgc-core/src/ingest.rs +++ b/crates/mtgc-core/src/ingest.rs @@ -23,7 +23,6 @@ use crate::error::{CoreError, Result}; use crate::model::{ColorSet, Legalities}; const BULK_INDEX_URL: &str = "https://api.scryfall.com/bulk-data"; -const USER_AGENT: &str = "mtgc/0.1 (personal library catalog)"; const STAGE_INDEX: &str = "index"; const STAGE_ORACLE: &str = "oracle"; @@ -202,7 +201,7 @@ fn build_into( overall_percent: overall_percent(&stages, STAGE_ORACLE, 0.0), stages: stages.clone(), }); - let reader = http_reader(&bulk.oracle_uri)?; + let reader = crate::http::get_reader(&bulk.oracle_uri)?; // Progress wraps the *compressed* stream, since `compressed_size` is what // the bulk index reports; decompression happens after the byte count. let reader = progress_reader( @@ -222,7 +221,7 @@ fn build_into( overall_percent: overall_percent(&stages, STAGE_PRINTINGS, 0.0), stages: stages.clone(), }); - let reader = http_reader(&bulk.default_uri)?; + let reader = crate::http::get_reader(&bulk.default_uri)?; let reader = progress_reader( reader, bulk.default_size, @@ -344,27 +343,12 @@ struct BulkUris { default_size: u64, } -/// HTTP agent with timeouts. Without them a stalled connection hangs forever: -/// cancellation is only polled per *parsed record*, so if no bytes arrive, no -/// records parse and `CancelToken` is never consulted — the ingest wedges with -/// no way out. Mobile networks stall like this routinely. -/// -/// `timeout_read` is per read call, not for the whole transfer, so a -/// multi-hundred-megabyte download is fine as long as it keeps making progress. -fn agent() -> ureq::Agent { - ureq::AgentBuilder::new() - .timeout_connect(std::time::Duration::from_secs(30)) - .timeout_read(std::time::Duration::from_secs(60)) - .build() -} - +/// The agent's timeouts matter especially here: cancellation is only polled per +/// *parsed record*, so if no bytes arrive, no records parse and `CancelToken` +/// is never consulted — without a read timeout the ingest wedges with no way +/// out. See [`crate::http::agent`]. fn fetch_bulk_uris() -> Result { - let index: BulkIndex = agent() - .get(BULK_INDEX_URL) - .set("User-Agent", USER_AGENT) - .set("Accept", "application/json") - .call()? - .into_json()?; + let index: BulkIndex = crate::http::get_json(BULK_INDEX_URL)?; let find = |kind: &str| { index @@ -383,11 +367,6 @@ fn fetch_bulk_uris() -> Result { }) } -fn http_reader(url: &str) -> Result { - let resp = agent().get(url).set("User-Agent", USER_AGENT).call()?; - Ok(BufReader::with_capacity(1 << 20, resp.into_reader())) -} - // ---- Streaming JSONL parse ---------------------------------------------- /// Deserialize newline-delimited JSON record-by-record, invoking `f` per record diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 34b4cdb..7411754 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -10,9 +10,11 @@ pub mod decklist; pub mod decks; pub mod error; pub mod filters; +pub(crate) mod http; pub mod ingest; pub mod library; pub mod model; +pub mod news; pub mod search; pub mod sync; pub mod synergy; diff --git a/crates/mtgc-core/src/news.rs b/crates/mtgc-core/src/news.rs new file mode 100644 index 0000000..e051f7a --- /dev/null +++ b/crates/mtgc-core/src/news.rs @@ -0,0 +1,493 @@ +//! Keeping up with the game outside your own collection: set releases, and +//! links to Wizards' news articles. +//! +//! Two sources with very different reliability, kept deliberately separate so +//! one failing can't take the other down. +//! +//! **Sets** come from Scryfall's documented `/sets` endpoint — stable JSON, +//! same service the card data already comes from. +//! +//! **Articles** have no supported source at all. Wizards publishes no RSS or +//! Atom feed and no public API, and their news page is a server-rendered Nuxt +//! app whose CSS classes are per-build hashes. So we read the rendered HTML, +//! but only for what's cheap and stable: an article's **title and link**. The +//! app links out rather than reproducing anyone's writing or images. +//! +//! [`parse_articles`] therefore keys on nothing cosmetic. It looks for anchors +//! pointing at `/en/news//` and takes the anchor's own text as +//! the title, which is how that page is built. Category index pages are the +//! two-segment form (`/en/news/announcements`) and fall out naturally. A +//! restyle can't break this; only a change to their URL scheme, or to putting +//! titles somewhere other than the link, would — and the tests below fail +//! loudly when that happens instead of the page quietly going empty. + +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +const SETS_URL: &str = "https://api.scryfall.com/sets"; +const NEWS_URL: &str = "https://magic.wizards.com/en/news"; +const NEWS_ORIGIN: &str = "https://magic.wizards.com"; + +/// Set categories that aren't interesting as "releases". +/// +/// Scryfall models tokens, art series, promo packs and Alchemy drops as their +/// own sets sharing a release date with the real one, so a single set week +/// otherwise shows up as six near-identical rows. +const NOISE_SET_TYPES: &[&str] = &[ + "token", + "memorabilia", + "promo", + "alchemy", + "minigame", + "vanguard", + "treasure_chest", +]; + +/// A published or upcoming Magic set. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SetInfo { + pub code: String, + pub name: String, + pub set_type: String, + /// ISO date (`YYYY-MM-DD`). Absent for a few unscheduled sets, which sort last. + pub released_at: Option, + pub card_count: i64, + pub icon_uri: String, + /// Scryfall's page for the set, for "see everything in this set". + pub scryfall_uri: String, +} + +/// A news article — title and link only, by design. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Article { + /// URL segment Wizards files it under, e.g. "announcements", "feature". + pub category: String, + pub title: String, + /// Absolute URL, for opening in the system browser. + pub url: String, +} + +/// Scryfall's `/sets` response. +#[derive(Deserialize)] +struct SetsResponse { + data: Vec, +} + +#[derive(Deserialize)] +struct RawSet { + code: String, + name: String, + #[serde(default)] + set_type: String, + #[serde(default)] + released_at: Option, + #[serde(default)] + card_count: i64, + #[serde(default)] + icon_svg_uri: String, + #[serde(default)] + scryfall_uri: String, + /// Digital-only sets can't be bought in paper, so they're not "releases" + /// in the sense this page means. + #[serde(default)] + digital: bool, +} + +/// Parse Scryfall's `/sets` payload into release-worthy sets, newest first. +/// +/// Filters out digital-only sets and the [`NOISE_SET_TYPES`]. Sets with no +/// announced date sort last rather than being dropped — an unscheduled set is +/// still news. +pub fn parse_sets(json: &str) -> Result> { + let resp: SetsResponse = serde_json::from_str(json)?; + let mut sets: Vec = resp + .data + .into_iter() + .filter(|s| !s.digital && !NOISE_SET_TYPES.contains(&s.set_type.as_str())) + .map(|s| SetInfo { + code: s.code, + name: s.name, + set_type: s.set_type, + released_at: s.released_at.filter(|d| !d.is_empty()), + card_count: s.card_count, + icon_uri: s.icon_svg_uri, + scryfall_uri: s.scryfall_uri, + }) + .collect(); + // Newest first; undated last. Reverse-lexicographic works because the + // dates are zero-padded ISO. + sets.sort_by(|a, b| match (&a.released_at, &b.released_at) { + (Some(x), Some(y)) => y.cmp(x), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.name.cmp(&b.name), + }); + Ok(sets) +} + +/// Extract article titles and links from the rendered news page. +/// +/// Never fails: unrecognised markup yields an empty list, leaving the caller to +/// decide what to show. A hard error here would be indistinguishable from being +/// offline, and neither should take down the rest of the page. +pub fn parse_articles(html: &str) -> Vec
{ + let mut out: Vec
= Vec::new(); + let mut rest = html; + + while let Some(start) = rest.find("') else { break }; + let tag = &rest[..open_end]; + let body_start = open_end + 1; + let Some(close) = rest[body_start..].find(" { + if title.chars().count() > existing.title.chars().count() { + existing.title = title; + } + } + None => out.push(Article { category, title, url }), + } + } + out +} + +/// The `` of an article path, or `None` if this isn't one. +/// +/// Article paths have exactly two segments below `/en/news/`. The one-segment +/// form is a category index ("/en/news/announcements"), which is not an article. +fn article_path(href: &str) -> Option<(String, String)> { + let path = href.strip_prefix(NEWS_ORIGIN).unwrap_or(href); + let path = path.split(['?', '#']).next().unwrap_or(path); + let rest = path.strip_prefix("/en/news/")?; + let mut parts = rest.trim_end_matches('/').split('/'); + let category = parts.next().filter(|s| !s.is_empty())?; + let slug = parts.next().filter(|s| !s.is_empty())?; + if parts.next().is_some() { + return None; // deeper than an article + } + Some((category.to_string(), slug.to_string())) +} + +/// Value of `name="..."` in an HTML open tag. +fn attr(tag: &str, name: &str) -> Option { + let key = format!("{name}=\""); + let start = tag.find(&key)? + key.len(); + let end = tag[start..].find('"')?; + Some(decode_entities(&tag[start..start + end])) +} + +/// Visible text of a fragment: tags stripped, entities decoded, whitespace +/// collapsed. Anchor bodies wrap their text in styling spans. +fn text_of(fragment: &str) -> String { + let mut plain = String::with_capacity(fragment.len()); + let mut in_tag = false; + for ch in fragment.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + c if !in_tag => plain.push(c), + _ => {} + } + } + decode_entities(&plain).split_whitespace().collect::>().join(" ") +} + +/// Decode the HTML entities this page actually uses. +/// +/// Deliberately partial: titles are display text, so an unrecognised entity +/// surviving verbatim is a cosmetic blemish, not a correctness problem — and +/// far better than pulling in a dependency to be exhaustive. +fn decode_entities(s: &str) -> String { + if !s.contains('&') { + return s.to_string(); + } + let named = [ + ("&", "&"), + ("<", "<"), + (">", ">"), + (""", "\""), + ("'", "'"), + (" ", " "), + ("—", "—"), + ("–", "–"), + ("…", "…"), + ("•", "•"), + ("©", "©"), + ("−", "−"), + // Product names carry these constantly ("Magic: The Gathering® | The + // Hobbit™"). The live page happens to emit them as literal UTF-8, but + // handling both forms costs nothing. + ("®", "®"), + ("™", "™"), + // Smart quotes, which article titles are full of. + ("’", "\u{2019}"), + ("‘", "\u{2018}"), + ("”", "\u{201D}"), + ("“", "\u{201C}"), + ("é", "é"), + ("á", "á"), + ("â", "â"), + ("í", "í"), + ("ó", "ó"), + ("Ó", "Ó"), + ("ö", "ö"), + ]; + let mut out = s.to_string(); + for (from, to) in named { + if out.contains(from) { + out = out.replace(from, to); + } + } + // Numeric forms, decimal and hex. + while let Some(start) = out.find("&#") { + let Some(len) = out[start..].find(';') else { break }; + let body = &out[start + 2..start + len]; + let parsed = body + .strip_prefix(['x', 'X']) + .and_then(|h| u32::from_str_radix(h, 16).ok()) + .or_else(|| body.parse::().ok()) + .and_then(char::from_u32); + match parsed { + Some(c) => out.replace_range(start..=start + len, &c.to_string()), + // Unparseable: leave it alone, but don't spin on it forever. + None => break, + } + } + out +} + +/// Fetch the current set list from Scryfall. +pub fn fetch_sets() -> Result> { + parse_sets(&crate::http::get_string(SETS_URL)?) +} + +/// Fetch the latest article links from the Wizards news page. +pub fn fetch_articles() -> Result> { + Ok(parse_articles(&crate::http::get_string(NEWS_URL)?)) +} + +/// Every card in a set, as raw Scryfall JSON pages. +/// +/// Returned unparsed: the caller pairs these with local ownership data, and the +/// shape it needs is the shell's concern rather than the core's. +pub fn fetch_set_cards_json(set_code: &str) -> Result { + let url = format!( + "https://api.scryfall.com/cards/search?q=set%3A{}&unique=cards&order=set", + urlencode(set_code) + ); + crate::http::get_string(&url) +} + +/// Follow a Scryfall-supplied `next_page` URL. +/// +/// Restricted to Scryfall so a compromised or malformed response can't redirect +/// us at an arbitrary host. +pub fn fetch_url(url: &str) -> Result { + if !url.starts_with("https://api.scryfall.com/") { + return Err(crate::error::CoreError::Other(format!( + "refusing to follow a page link outside Scryfall: {url}" + ))); + } + crate::http::get_string(url) +} + +/// Percent-encode a set code. Codes are alphanumeric in practice; this exists +/// so a hand-typed or future code can't inject query syntax. +fn urlencode(s: &str) -> String { + s.chars() + .flat_map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + vec![c] + } else { + format!("%{:02X}", c as u32).chars().collect() + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Shaped like the real page: hashed CSS classes, the title repeated in a + /// heading and again as the anchor's text, category index links in the nav, + /// and styling spans inside the anchor body. + const NEWS_FIXTURE: &str = r##" + + +
+
Announcements
+

Everything Announced for 2027

+

Dive into a new plane.

+ + Everything Announced for 2027 + +
+ + Rules Update & Errata + Too deep to be an article + +"##; + + #[test] + fn extracts_articles_by_link_shape_not_by_styling() { + let got = parse_articles(NEWS_FIXTURE); + + let titles: Vec<&str> = got.iter().map(|a| a.title.as_str()).collect(); + assert_eq!( + titles, + vec![ + "Everything Announced for 2027", + // Entities decoded, nested spans flattened, whitespace collapsed. + "Magic: The Gathering® | The Hobbit™ Prerelease Guide", + "Rules Update & Errata", + ] + ); + assert_eq!(got[0].category, "announcements"); + assert_eq!(got[1].category, "feature"); + assert_eq!( + got[0].url, + "https://magic.wizards.com/en/news/announcements/everything-announced-2027", + "relative hrefs become absolute so they can be opened" + ); + assert_eq!( + got[2].url, "https://magic.wizards.com/en/news/feature/rules-update", + "already-absolute hrefs are left alone" + ); + } + + #[test] + fn ignores_category_pages_unrelated_links_and_repeats() { + let got = parse_articles(NEWS_FIXTURE); + let urls: Vec<&str> = got.iter().map(|a| a.url.as_str()).collect(); + + assert!( + !urls.iter().any(|u| u.ends_with("/en/news/announcements")), + "a category index is not an article" + ); + assert!(!urls.iter().any(|u| u.contains("/products/"))); + assert!(!urls.iter().any(|u| u.contains("nested")), "deeper paths aren't articles"); + assert_eq!( + urls.iter().filter(|u| u.contains("prerelease-guide")).count(), + 1, + "the same article linked twice should appear once" + ); + } + + #[test] + fn a_generic_call_to_action_never_wins_over_the_real_title() { + // Observed on the live page: the hero article is linked first by a + // "Learn More" button, then again by its heading. Taking the first + // link would file the article under "Learn More". + let html = r#" + Learn More +

The Actual Headline of the Story

+ The Actual Headline of the Story + "#; + let got = parse_articles(html); + assert_eq!(got.len(), 1); + assert_eq!(got[0].title, "The Actual Headline of the Story"); + } + + #[test] + fn unrecognisable_markup_yields_nothing_rather_than_failing() { + // The day Wizards redesigns, this is what we get. It must not panic, + // and must not be mistaken for "there is no news". + assert!(parse_articles("

hello

").is_empty()); + assert!(parse_articles("").is_empty()); + assert!(parse_articles("").is_empty(), "empty title"); + assert!(parse_articles(" = got.iter().map(|s| s.code.as_str()).collect(); + assert_eq!( + codes, + vec!["ecl", "fut", "unk"], + "newest dated first, undated last" + ); + assert_eq!(got[0].card_count, 408); + assert_eq!(got[2].released_at, None); + } + + #[test] + fn token_and_digital_sets_are_not_releases() { + let got = parse_sets(SETS_FIXTURE).unwrap(); + let codes: Vec<&str> = got.iter().map(|s| s.code.as_str()).collect(); + assert!( + !codes.contains(&"tecl"), + "a token set shares its date with the real one and would just be noise" + ); + assert!(!codes.contains(&"yecl"), "digital-only can't be bought in paper"); + } + + #[test] + fn malformed_set_json_is_an_error_not_a_panic() { + assert!(parse_sets("not json").is_err()); + assert!(parse_sets(r#"{"data":[]}"#).unwrap().is_empty()); + } + + #[test] + fn set_codes_are_escaped_into_the_query() { + assert_eq!(urlencode("ecl"), "ecl"); + assert_eq!(urlencode("a b"), "a%20b"); + assert_eq!(urlencode("x&q=1"), "x%26q%3D1", "can't break out of the query"); + } +} + diff --git a/package-lock.json b/package-lock.json index ebdaa3f..765f6a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", + "@tauri-apps/plugin-opener": "^2.5.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -1338,6 +1339,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", + "integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index addd2e5..943135b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", + "@tauri-apps/plugin-opener": "^2.5.4", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d24115d..83f978a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,3 +19,4 @@ tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" mtgc-core = { path = "../crates/mtgc-core" } +tauri-plugin-opener = "2.5.4" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8a73be3..9f4600e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,6 +2,12 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capabilities for the main window", - "windows": ["main"], - "permissions": ["core:default", "dialog:default"] + "windows": [ + "main" + ], + "permissions": [ + "core:default", + "dialog:default", + "opener:default" + ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ef0d99..2c5a07f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod news; mod state; mod sync; @@ -48,7 +49,8 @@ fn sync_plugin() -> tauri::plugin::TauriPlugin { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init()); + let builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()); #[cfg(target_os = "android")] let builder = builder.plugin(sync_plugin()); @@ -74,6 +76,8 @@ pub fn run() { sync::sync_forget_document, sync::sync_now, sync::convert_legacy_library, + news::get_news, + news::get_set_cards, commands::search_cards, commands::add_owned, commands::set_owned, diff --git a/src-tauri/src/news.rs b/src-tauri/src/news.rs new file mode 100644 index 0000000..0caa228 --- /dev/null +++ b/src-tauri/src/news.rs @@ -0,0 +1,347 @@ +//! The News tab's backend: set releases, article links, and a set's cards. +//! +//! Both feeds are cached to one file in the app data dir so the tab renders +//! instantly and still shows something useful offline. Nothing here is load +//! bearing for the rest of the app — every failure degrades to stale or empty +//! content rather than an error the user has to clear. + +use std::path::{Path, PathBuf}; + +use mtgc_core::news::{Article, SetInfo}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; + +use crate::state::AppState; + +type Db<'a> = tauri::State<'a, std::sync::Mutex>; + +/// How long cached content is considered fresh. Set schedules and article +/// listings move on the order of days, so refetching more often than this is +/// just noise on someone else's servers. +const MAX_AGE_SECS: i64 = 60 * 60 * 6; + +fn cache_path(data_dir: &Path) -> PathBuf { + data_dir.join("news-cache.json") +} + +/// Everything the News tab renders, plus how old it is. +#[derive(Serialize, Deserialize, Default, Clone)] +pub struct News { + pub sets: Vec, + pub articles: Vec
, + /// Unix seconds when this was last fetched successfully. 0 when it has + /// never succeeded, which the UI shows as "not loaded yet" rather than + /// as very stale. + pub fetched_at: i64, + /// Whether the last attempt reached Wizards. The articles half has no + /// supported source and is expected to fail sometimes; the UI says so for + /// that section alone and leaves the releases list intact. + pub articles_ok: bool, +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn read_cache(data_dir: &Path) -> Option { + let text = std::fs::read_to_string(cache_path(data_dir)).ok()?; + serde_json::from_str(&text).ok() +} + +fn write_cache(data_dir: &Path, news: &News) { + let Ok(text) = serde_json::to_string(news) else { + return; + }; + // Write-then-rename so a kill mid-write can't leave a half-written cache + // that fails to parse on next launch. + let tmp = cache_path(data_dir).with_extension("json.tmp"); + if std::fs::write(&tmp, text).is_ok() { + let _ = std::fs::rename(&tmp, cache_path(data_dir)); + } +} + +/// Set releases and article links, from cache when it's fresh. +/// +/// `refresh` forces a fetch. Otherwise cached content younger than +/// [`MAX_AGE_SECS`] is returned untouched. +/// +/// A failed fetch is never an error: whatever is cached is returned instead, so +/// opening the tab offline shows the last known state rather than an empty page +/// with a message. +#[tauri::command] +pub fn get_news(app: AppHandle, refresh: bool) -> Result { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let cached = read_cache(&data_dir); + + if !refresh { + if let Some(c) = &cached { + if c.fetched_at > 0 && now_secs() - c.fetched_at < MAX_AGE_SECS { + return Ok(c.clone()); + } + } + } + + // Each half is fetched independently so one being down can't blank the + // other. Falling back to cached values means a transient failure looks + // like "not updated" rather than "everything vanished". + let sets = match mtgc_core::news::fetch_sets() { + Ok(s) => s, + Err(e) => { + crate::mlog!("get_news: sets fetch failed: {e}"); + cached.as_ref().map(|c| c.sets.clone()).unwrap_or_default() + } + }; + let (articles, articles_ok) = match mtgc_core::news::fetch_articles() { + // An empty list from a successful fetch means their markup changed + // under us — treat it as a failure so the UI can say so, and keep + // whatever we last saw. + Ok(a) if !a.is_empty() => (a, true), + Ok(_) => { + crate::mlog!("get_news: article page fetched but yielded nothing — markup may have changed"); + (cached.as_ref().map(|c| c.articles.clone()).unwrap_or_default(), false) + } + Err(e) => { + crate::mlog!("get_news: articles fetch failed: {e}"); + (cached.as_ref().map(|c| c.articles.clone()).unwrap_or_default(), false) + } + }; + + let anything = !sets.is_empty() || !articles.is_empty(); + let news = News { + sets, + articles, + // Only claim a fetch time if we actually got something; otherwise keep + // the old one so the UI keeps reporting the true age of what it shows. + fetched_at: if anything { + now_secs() + } else { + cached.as_ref().map(|c| c.fetched_at).unwrap_or(0) + }, + articles_ok, + }; + if anything { + write_cache(&data_dir, &news); + } + Ok(news) +} + +/// One card in a set. +/// +/// Shaped as a full [`mtgc_core::CardRef`] plus ownership, rather than the +/// handful of fields the grid strictly needs, so the News tab can render the +/// same `CardTile` as everywhere else — and get click-to-preview, hover +/// preview and consistent styling instead of a lookalike that does nothing +/// when clicked. +#[derive(Serialize)] +pub struct SetCard { + #[serde(flatten)] + pub card: mtgc_core::CardRef, + pub collector_number: String, + pub quantity_owned: i64, +} + +/// Scryfall's `/cards/search` response shape, only the fields shown here. +#[derive(Deserialize)] +struct SearchPage { + #[serde(default)] + data: Vec, + #[serde(default)] + has_more: bool, + #[serde(default)] + next_page: Option, +} + +#[derive(Deserialize)] +struct RawCard { + #[serde(default)] + oracle_id: Option, + name: String, + #[serde(default)] + type_line: String, + #[serde(default)] + collector_number: String, + #[serde(default)] + cmc: f64, + #[serde(default)] + colors: Option>, + #[serde(default)] + color_identity: Vec, + #[serde(default)] + produced_mana: Option>, + #[serde(default)] + legalities: std::collections::HashMap, + #[serde(default)] + prices: Option, + #[serde(default)] + image_uris: Option, + #[serde(default)] + card_faces: Option>, +} + +#[derive(Deserialize)] +struct Prices { + #[serde(default)] + usd: Option, +} + +#[derive(Deserialize)] +struct ImageUris { + #[serde(default)] + normal: Option, +} + +#[derive(Deserialize)] +struct Face { + #[serde(default)] + image_uris: Option, + #[serde(default)] + type_line: Option, + #[serde(default)] + colors: Option>, +} + +/// Every card in a set, annotated with what the user owns. +/// +/// Fetched live rather than read from `reference.db`, so a set released (or +/// spoiled) since the last card-data download still works. Ownership comes from +/// the local library by `oracle_id`, which both sides agree on. +#[tauri::command] +pub fn get_set_cards(app: AppHandle, db: Db, code: String) -> Result, String> { + let _ = &app; + let mut cards: Vec = Vec::new(); + let mut url: Option = None; + + // Scryfall pages at 175 cards; a big set is three requests. + for _ in 0..12 { + let body = match &url { + None => mtgc_core::news::fetch_set_cards_json(&code), + Some(next) => mtgc_core::news::fetch_url(next), + } + .map_err(|e| e.to_string())?; + let page: SearchPage = serde_json::from_str(&body).map_err(|e| e.to_string())?; + cards.extend(page.data); + match (page.has_more, page.next_page) { + (true, Some(next)) => url = Some(next), + _ => break, + } + } + + let library_path = db.lock().unwrap().working_db_path.clone(); + let conn = mtgc_core::db::open_library(&library_path).map_err(|e| e.to_string())?; + let mut owned = conn + .prepare("SELECT quantity_owned FROM library_cards WHERE oracle_id = ?1") + .map_err(|e| e.to_string())?; + + let mut out = Vec::with_capacity(cards.len()); + for c in cards { + let Some(oracle_id) = c.oracle_id else { + continue; // art-series and similar records carry no oracle identity + }; + let quantity_owned: i64 = owned + .query_row([&oracle_id], |r| r.get(0)) + .unwrap_or(0); + let front = c.card_faces.as_ref().and_then(|f| f.first()); + let image_uri = c + .image_uris + .as_ref() + .and_then(|i| i.normal.clone()) + .or_else(|| front.and_then(|f| f.image_uris.as_ref()).and_then(|i| i.normal.clone())) + .unwrap_or_default(); + // Only transform/MDFC/reversible cards have a distinct second face; + // split and adventure cards share one combined image. + let back_image_uri = c + .card_faces + .as_ref() + .and_then(|f| f.get(1)) + .and_then(|f| f.image_uris.as_ref()) + .and_then(|i| i.normal.clone()) + .unwrap_or_default(); + // Top-level `colors` is absent on double-faced cards; fall back to the + // front face, same as the bulk ingester does. + let colors = c + .colors + .clone() + .or_else(|| front.and_then(|f| f.colors.clone())) + .unwrap_or_default(); + let type_line = if c.type_line.is_empty() { + front.and_then(|f| f.type_line.clone()).unwrap_or_default() + } else { + c.type_line.clone() + }; + let produced = c.produced_mana.clone().unwrap_or_default(); + out.push(SetCard { + card: mtgc_core::CardRef { + oracle_id, + name: c.name, + mana_value: c.cmc, + colors: mtgc_core::ColorSet::from_letters(&colors), + color_identity: mtgc_core::ColorSet::from_letters(&c.color_identity), + // Battles and Planes are printed sideways. Detected from the + // front face's type line, since Battle cards report a + // "transform" layout. + horizontal: type_line.contains("Battle") || type_line.contains("Plane "), + type_line, + image_uri, + back_image_uri, + produced_mana: mtgc_core::ColorSet::from_letters(&produced), + produces_colorless: produced.iter().any(|m| m == "C"), + price_usd: c + .prices + .as_ref() + .and_then(|p| p.usd.as_ref()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0), + legalities: mtgc_core::Legalities::from_scryfall(&c.legalities), + }, + collector_number: c.collector_number, + quantity_owned, + }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("mtgc-news-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn the_cache_round_trips_and_survives_a_half_written_file() { + let dir = scratch("cache"); + assert!(read_cache(&dir).is_none(), "no cache yet"); + + let news = News { + sets: vec![], + articles: vec![Article { + category: "feature".into(), + title: "Something".into(), + url: "https://example.test/a".into(), + }], + fetched_at: 1_700_000_000, + articles_ok: true, + }; + write_cache(&dir, &news); + let back = read_cache(&dir).expect("should read back"); + assert_eq!(back.articles.len(), 1); + assert_eq!(back.fetched_at, 1_700_000_000); + + // Truncated JSON must read as "no cache", not blow up on launch. + std::fs::write(cache_path(&dir), "{\"sets\":[").unwrap(); + assert!(read_cache(&dir).is_none()); + + assert!( + !dir.join("news-cache.json.tmp").exists(), + "the atomic-write temporary must not be left behind" + ); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/src/App.tsx b/src/App.tsx index 0da9eef..adb97d7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,8 +16,9 @@ import { CardEntry } from "./views/CardEntry"; import { LibraryBrowser } from "./views/LibraryBrowser"; import { CardSearch } from "./views/CardSearch"; import { DeckView } from "./views/DeckView"; +import { NewsView } from "./views/NewsView"; -type Tab = "add" | "library" | "search" | "decks" | "info"; +type Tab = "add" | "library" | "search" | "decks" | "news" | "info"; const svg = (children: ReactNode, size = 20) => ( , ), }, + { + id: "news", + label: "News", + icon: svg( + <> + + + + + , + ), + }, ]; /** Mobile-only tab (see `.bottom-nav`) holding card-database management — @@ -833,6 +846,10 @@ export default function App() { ) : tab === "decks" ? ( + ) : tab === "news" ? ( + // Library-independent, so unlike the others it isn't keyed on the + // document — switching libraries doesn't change the news. + ) : ( // The per-install status page: card data, library sync, and the // collection summary. On mobile it's the only home for the diff --git a/src/CommandPalette.tsx b/src/CommandPalette.tsx index baa9d77..4006006 100644 --- a/src/CommandPalette.tsx +++ b/src/CommandPalette.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from "react"; import { api, CardRef, Deck } from "./api"; import { DialogFrame, useClickPreview } from "./components"; -type Tab = "add" | "library" | "search" | "decks"; +// Kept in step with App.tsx's Tab union by hand — it isn't exported. +type Tab = "add" | "library" | "search" | "decks" | "news"; interface PaletteItem { key: string; @@ -95,6 +96,7 @@ export function CommandPalette({ { key: "nav-library", label: "Go to Library", run: () => onNavigate("library") }, { key: "nav-search", label: "Go to Card Search", run: () => onNavigate("search") }, { key: "nav-decks", label: "Go to Decks", run: () => onNavigate("decks") }, + { key: "nav-news", label: "Go to News", run: () => onNavigate("news") }, { 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 }, diff --git a/src/api.ts b/src/api.ts index d2e6ec6..da1bde1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -164,6 +164,43 @@ export interface SyncReport { }; } +/** A published or upcoming Magic set, from Scryfall. */ +export interface SetInfo { + code: string; + name: string; + set_type: string; + /** ISO date; null for sets with no announced date, which sort last. */ + released_at: string | null; + card_count: number; + icon_uri: string; + scryfall_uri: string; +} + +/** A news article — title and link only; the app links out rather than + * reproducing anyone's writing. */ +export interface Article { + category: string; + title: string; + url: string; +} + +export interface News { + sets: SetInfo[]; + articles: Article[]; + /** Unix seconds of the last successful fetch; 0 if never. */ + fetched_at: number; + /** False when the article source couldn't be reached or its markup changed. + * Only that section is affected — releases stay usable. */ + articles_ok: boolean; +} + +/** A card in a set. Extends CardRef so it renders with the same CardTile — + * and therefore the same click-to-preview — as every other card in the app. */ +export interface SetCard extends CardRef { + collector_number: string; + quantity_owned: number; +} + export interface LibraryStats { unique_cards: number; total_copies: number; @@ -254,6 +291,11 @@ export const api = { /** Convert a pre-document library.db into the current format. */ convertLegacyLibrary: (path: string) => invoke("convert_legacy_library", { path }), + /** Set releases and article links. `refresh` forces a fetch past the cache. */ + getNews: (refresh = false) => invoke("get_news", { refresh }), + /** Every card in a set, annotated with how many you own. */ + getSetCards: (code: string) => invoke("get_set_cards", { code }), + searchCards: (query: string, limit = 20) => invoke("search_cards", { query, limit }), diff --git a/src/styles.css b/src/styles.css index 7ffde9f..4f52838 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2636,3 +2636,124 @@ tbody tr:hover { } } + +/* ============================================================ + News + ============================================================ */ + +/* Releases and articles side by side on desktop; stacked once there isn't + room, which the ≤900px rule below handles. */ +.news-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-5); + align-items: start; +} +@media (max-width: 900px) { + .news-columns { + grid-template-columns: 1fr; + } +} +.news-col { + min-width: 0; +} +.news-col .group-header { + margin-top: 0; +} + +/* Both feeds are dense, uniform rows rather than the default result-list's + roomier search-result spacing. */ +.set-list li, +.article-list li { + align-items: center; + gap: var(--space-3); + padding: 8px 12px; +} + +/* --- releases ------------------------------------------------------------ + A fixed-width date gutter is what makes this scan as a timeline: the dates + form one column and every set name starts at the same x, regardless of how + long the date renders in the user's locale. */ +.set-date { + flex: 0 0 52px; + display: flex; + flex-direction: column; + align-items: flex-end; + line-height: 1.15; +} +.set-date-day { + font-size: var(--fs-caption); + font-weight: 700; + color: var(--text-strong); + white-space: nowrap; +} +.set-date-year { + font-size: var(--fs-micro); + color: var(--faint); + font-variant-numeric: tabular-nums; +} +/* Not-yet-released sets get the accent, so "what's coming" is findable at a + glance without reading any dates. */ +.set-row.upcoming .set-date-day { + color: var(--accent-hover); +} + +/* Scryfall's set symbols are SVGs with dark ink baked in, so they disappear + against a dark panel — inverted rather than shipping our own icon set. */ +.set-icon { + width: 24px; + height: 24px; + flex-shrink: 0; + filter: invert(1); + opacity: 0.62; + transition: opacity 0.12s; +} +.set-row:hover .set-icon { + opacity: 0.95; +} +.set-row-main { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} +.set-name { + font-weight: 600; + color: var(--text-strong); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.set-row-meta { + font-size: var(--fs-micro); + color: var(--muted); + text-transform: capitalize; +} + +/* --- articles ------------------------------------------------------------ */ + +/* Fixed width so every title starts at the same x. Variable-width chips left + the titles ragged, which read as misalignment rather than as structure. + The categories are a small closed set ("announcements" is the longest), + so a fixed slot is safe. */ +.article-cat { + flex: 0 0 88px; + font-size: var(--fs-micro); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--faint); + text-align: right; + line-height: 1.3; + overflow-wrap: anywhere; +} +.article-list li:hover .article-cat { + color: var(--accent-quiet); +} +.article-title { + font-weight: 600; + color: var(--text-strong); + line-height: 1.35; + overflow-wrap: anywhere; + min-width: 0; +} diff --git a/src/views/NewsView.tsx b/src/views/NewsView.tsx new file mode 100644 index 0000000..9994af8 --- /dev/null +++ b/src/views/NewsView.tsx @@ -0,0 +1,260 @@ +import { useCallback, useEffect, useState } from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { api, News, SetCard, SetInfo } from "../api"; +import { CardTile, QtyStepper } from "../components"; + +/** Sets whose release date is still ahead of us, newest-announced first. */ +function splitByRelease(sets: SetInfo[]): { upcoming: SetInfo[]; released: SetInfo[] } { + const today = new Date().toISOString().slice(0, 10); + const upcoming = sets.filter((s) => s.released_at !== null && s.released_at > today).reverse(); + const released = sets.filter((s) => s.released_at === null || s.released_at <= today); + return { upcoming, released }; +} + +function formatDate(iso: string | null): string { + if (!iso) return "Date TBA"; + const d = new Date(`${iso}T00:00:00`); + return Number.isNaN(d.getTime()) + ? iso + : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +/** Date split for the timeline gutter, so every row's name starts at the same x. */ +function dateParts(iso: string | null): { top: string; bottom: string } { + if (!iso) return { top: "TBA", bottom: "" }; + const d = new Date(`${iso}T00:00:00`); + if (Number.isNaN(d.getTime())) return { top: iso, bottom: "" }; + return { + top: d.toLocaleDateString(undefined, { month: "short", day: "numeric" }), + bottom: String(d.getFullYear()), + }; +} + +function formatAge(fetchedAt: number): string { + if (!fetchedAt) return "not loaded yet"; + const mins = Math.floor((Date.now() / 1000 - fetchedAt) / 60); + if (mins < 2) return "just now"; + if (mins < 60) return `${mins} minutes ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.floor(hours / 24); + return `${days} day${days === 1 ? "" : "s"} ago`; +} + +/** How many sets to show before "Show more" — a full list is 500+ rows. */ +const RELEASED_PAGE = 12; + +/** + * What's happening in Magic outside your own collection: set releases you can + * add cards from, and links to Wizards' latest articles. + * + * The two halves are deliberately independent. Releases come from Scryfall's + * documented API; articles are scraped from a page with no supported feed, so + * that section is allowed to fail on its own without taking the page with it. + */ +export function NewsView() { + const [news, setNews] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [showAll, setShowAll] = useState(false); + + const [openSet, setOpenSet] = useState(null); + const [cards, setCards] = useState(null); + const [cardsError, setCardsError] = useState(null); + + const load = useCallback(async (refresh: boolean) => { + setBusy(true); + setError(null); + try { + setNews(await api.getNews(refresh)); + } catch (err) { + setError(String(err)); + } finally { + setBusy(false); + } + }, []); + + useEffect(() => { + load(false); + }, [load]); + + async function chooseSet(set: SetInfo) { + setOpenSet(set); + setCards(null); + setCardsError(null); + try { + setCards(await api.getSetCards(set.code)); + } catch (err) { + setCardsError(String(err)); + } + } + + async function adjust(oracleId: string, delta: number) { + const qty = await api.addOwned(oracleId, delta); + setCards((cs) => + cs ? cs.map((c) => (c.oracle_id === oracleId ? { ...c, quantity_owned: qty } : c)) : cs, + ); + } + + // --- a set's cards ------------------------------------------------------ + if (openSet) { + return ( +
+
+

+ {openSet.name} + {cards && {cards.length}} +

+
+ +
+
+
+ {formatDate(openSet.released_at)} · {openSet.set_type.replace(/_/g, " ")} · set code{" "} + {openSet.code.toUpperCase()} +
+ {cardsError ? ( +

{cardsError}

+ ) : !cards ? ( +

Loading cards…

+ ) : cards.length === 0 ? ( +

No cards listed for this set yet.

+ ) : ( +
+ {cards.map((c) => ( + adjust(c.oracle_id, -1)} + onIncrement={() => adjust(c.oracle_id, 1)} + /> + } + /> + ))} +
+ )} +
+ ); + } + + // --- the feed ----------------------------------------------------------- + const { upcoming, released } = splitByRelease(news?.sets ?? []); + const shown = showAll ? released : released.slice(0, RELEASED_PAGE); + + const setRow = (s: SetInfo, upcoming = false) => { + const { top, bottom } = dateParts(s.released_at); + return ( +
  • chooseSet(s)} + > + {/* Fixed-width date gutter: every set name then starts at the same x, + which is what turns this from a list into a timeline. */} + + {top} + {bottom} + + {s.icon_uri ? ( + + ) : ( + + )} + + {s.name} + + {s.set_type.replace(/_/g, " ")} · {s.card_count.toLocaleString()} cards + + +
  • + ); + }; + + return ( +
    +
    +

    News

    +
    + +
    +
    + + {error &&

    {error}

    } + {news && ( +
    + Updated {formatAge(news.fetched_at)} + {news.fetched_at === 0 && " — check your connection"} +
    + )} + + {!news && !error &&

    Loading…

    } + + {news && ( +
    +
    +

    Releases

    + {news.sets.length === 0 ? ( +

    Couldn't load set releases.

    + ) : ( + <> + {upcoming.length > 0 && ( + <> +
    Upcoming
    +
      {upcoming.map((s) => setRow(s, true))}
    + + )} +
    + Released +
    +
      {shown.map((s) => setRow(s))}
    + {released.length > shown.length && ( +
    + +
    + )} + + )} +
    + +
    +

    Latest from Wizards

    + {/* This half has no supported feed, so it's expected to break + eventually. Say so plainly and leave releases alone. */} + {news.articles.length === 0 ? ( +

    + {news.articles_ok + ? "No articles found." + : "Couldn't load the latest news right now."} +

    + ) : ( + <> + {!news.articles_ok && ( +

    Showing the last articles we could load.

    + )} +
      + {news.articles.map((a) => ( +
    • openUrl(a.url)} title={a.url}> + {/* Fixed-width so titles line up; the categories are a + small closed set, not free text. */} + {a.category.replace(/-/g, " ")} + {a.title} +
    • + ))} +
    +

    Articles open on magic.wizards.com.

    + + )} +
    +
    + )} +
    + ); +}