Add a News tab: set releases and links to Wizards' articles
The app knew nothing about the game outside your own collection — no way to see that a set drops next week, or that a Secret Lair was announced. Two sources with very different reliability, kept apart so one failing can't take the page down. Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches its cards live rather than reading reference.db, so a set released or spoiled since the last card-data download still works; ownership is joined locally by oracle_id, and the same add-to-library stepper works there as anywhere else. Articles have no supported source at all: Wizards publishes no RSS or Atom feed and no public API, and their page is a Nuxt app whose CSS classes are per-build hashes. So the app links out rather than reproducing anyone's writing, which keeps the scraped surface to titles and hrefs. parse_articles keys on nothing cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's own text as the title. A restyle can't break that; only a change to their URL scheme could, and the fixture tests fail loudly when it does rather than the page quietly going empty. Verified against a live capture: 15 articles, correct titles and categories. Two things the fixtures caught. Where an article is linked several times per card, the first link is a "Learn More" button, so the longest text wins rather than the first. And a *successful* fetch yielding zero articles is treated as a failure, since that is the signature of their markup changing. get_set_cards returns a full CardRef rather than the handful of fields the grid needs, so the tab renders the same CardTile as the rest of the app and gets click-to-preview instead of a lookalike that does nothing when clicked. Both feeds cache to news-cache.json for six hours and fall back to whatever was last seen, so opening the tab offline shows stale content with its age rather than an error. Also promotes the HTTP agent out of ingest.rs into a shared http module, so timeouts and User-Agent can't drift between the ingester and anything else that reaches the network. Adds tauri-plugin-opener for opening articles externally.
This commit is contained in:
458
Cargo.lock
generated
458
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
53
crates/mtgc-core/src/http.rs
Normal file
53
crates/mtgc-core/src/http.rs
Normal file
@@ -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<impl Read> {
|
||||
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<String> {
|
||||
Ok(agent()
|
||||
.get(url)
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.call()?
|
||||
.into_string()?)
|
||||
}
|
||||
|
||||
/// GET `url` and deserialize the JSON body.
|
||||
pub(crate) fn get_json<T: serde::de::DeserializeOwned>(url: &str) -> Result<T> {
|
||||
Ok(agent()
|
||||
.get(url)
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.set("Accept", "application/json")
|
||||
.call()?
|
||||
.into_json()?)
|
||||
}
|
||||
@@ -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<BulkUris> {
|
||||
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<BulkUris> {
|
||||
})
|
||||
}
|
||||
|
||||
fn http_reader(url: &str) -> Result<impl Read> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
493
crates/mtgc-core/src/news.rs
Normal file
493
crates/mtgc-core/src/news.rs
Normal file
@@ -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/<category>/<slug>` 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<String>,
|
||||
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<RawSet>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawSet {
|
||||
code: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
set_type: String,
|
||||
#[serde(default)]
|
||||
released_at: Option<String>,
|
||||
#[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<Vec<SetInfo>> {
|
||||
let resp: SetsResponse = serde_json::from_str(json)?;
|
||||
let mut sets: Vec<SetInfo> = 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<Article> {
|
||||
let mut out: Vec<Article> = Vec::new();
|
||||
let mut rest = html;
|
||||
|
||||
while let Some(start) = rest.find("<a ") {
|
||||
rest = &rest[start..];
|
||||
// Anchor open tag.
|
||||
let Some(open_end) = rest.find('>') else { break };
|
||||
let tag = &rest[..open_end];
|
||||
let body_start = open_end + 1;
|
||||
let Some(close) = rest[body_start..].find("</a") else {
|
||||
rest = &rest[body_start..];
|
||||
continue;
|
||||
};
|
||||
let body = &rest[body_start..body_start + close];
|
||||
rest = &rest[body_start + close..];
|
||||
|
||||
let Some(href) = attr(tag, "href") else { continue };
|
||||
let Some((category, _slug)) = article_path(&href) else {
|
||||
continue;
|
||||
};
|
||||
let title = text_of(body);
|
||||
if title.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let url = if href.starts_with("http") {
|
||||
href.clone()
|
||||
} else {
|
||||
format!("{NEWS_ORIGIN}{href}")
|
||||
};
|
||||
// The same article is linked several times per card — the image, the
|
||||
// heading, and a "Learn More" button. Keep the longest text: the
|
||||
// generic call-to-action is always shorter than the real title, and
|
||||
// preferring the *first* picks the button on the hero article.
|
||||
match out.iter_mut().find(|a| a.url == url) {
|
||||
Some(existing) => {
|
||||
if title.chars().count() > existing.title.chars().count() {
|
||||
existing.title = title;
|
||||
}
|
||||
}
|
||||
None => out.push(Article { category, title, url }),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `<category>` 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<String> {
|
||||
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::<Vec<_>>().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::<u32>().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<Vec<SetInfo>> {
|
||||
parse_sets(&crate::http::get_string(SETS_URL)?)
|
||||
}
|
||||
|
||||
/// Fetch the latest article links from the Wizards news page.
|
||||
pub fn fetch_articles() -> Result<Vec<Article>> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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##"
|
||||
<!DOCTYPE html><html><body>
|
||||
<nav class="css-a1B2c">
|
||||
<a href="/en/news/announcements">Announcements</a>
|
||||
<a href="/en/news/feature">Feature</a>
|
||||
<a href="/en/news/archive">Archive</a>
|
||||
<a href="/en/products/the-hobbit">Products</a>
|
||||
</nav>
|
||||
<div data-article-hub-card-v="1.8.1" class="css-P1ULX">
|
||||
<div class="css-KLPvC">Announcements</div>
|
||||
<h3 class="css-blB8m">Everything Announced for 2027</h3>
|
||||
<div class="css-mjrnX"><p>Dive into a new plane.</p></div>
|
||||
<a data-navigation-type="server-side" href="/en/news/announcements/everything-announced-2027" class="css-1+IR9">
|
||||
Everything Announced for 2027
|
||||
</a>
|
||||
</div>
|
||||
<div data-article-hub-card-v="1.8.1">
|
||||
<a href="/en/news/feature/the-hobbit-prerelease-guide">
|
||||
<span class="css-xY">Magic: The Gathering® | The Hobbit™</span> Prerelease Guide
|
||||
</a>
|
||||
<a href="/en/news/feature/the-hobbit-prerelease-guide" class="css-dup">
|
||||
<img src="/x.png" alt="">
|
||||
</a>
|
||||
</div>
|
||||
<a href="https://magic.wizards.com/en/news/feature/rules-update">Rules Update & Errata</a>
|
||||
<a href="/en/news/feature/deep/nested/path">Too deep to be an article</a>
|
||||
</body></html>
|
||||
"##;
|
||||
|
||||
#[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#"
|
||||
<a href="/en/news/feature/big-story">Learn More</a>
|
||||
<h3>The Actual Headline of the Story</h3>
|
||||
<a href="/en/news/feature/big-story">The Actual Headline of the Story</a>
|
||||
"#;
|
||||
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("<html><body><p>hello</p></body></html>").is_empty());
|
||||
assert!(parse_articles("").is_empty());
|
||||
assert!(parse_articles("<a href=\"/en/news/x/y\"></a>").is_empty(), "empty title");
|
||||
assert!(parse_articles("<a href=").is_empty(), "truncated tag");
|
||||
}
|
||||
|
||||
const SETS_FIXTURE: &str = r#"{
|
||||
"data": [
|
||||
{"code":"ecl","name":"Lorwyn Eclipsed","set_type":"expansion","released_at":"2026-01-23",
|
||||
"card_count":408,"icon_svg_uri":"https://svgs.scryfall.io/sets/ecl.svg",
|
||||
"scryfall_uri":"https://scryfall.com/sets/ecl","digital":false},
|
||||
{"code":"tecl","name":"Lorwyn Eclipsed Tokens","set_type":"token","released_at":"2026-01-23",
|
||||
"card_count":13,"icon_svg_uri":"","scryfall_uri":"","digital":false},
|
||||
{"code":"yecl","name":"Alchemy: Lorwyn Eclipsed","set_type":"alchemy","released_at":"2026-02-03",
|
||||
"card_count":30,"icon_svg_uri":"","scryfall_uri":"","digital":true},
|
||||
{"code":"fut","name":"Future Sight","set_type":"expansion","released_at":"2007-05-04",
|
||||
"card_count":180,"icon_svg_uri":"","scryfall_uri":"","digital":false},
|
||||
{"code":"unk","name":"Unannounced Thing","set_type":"expansion",
|
||||
"card_count":0,"icon_svg_uri":"","scryfall_uri":"","digital":false}
|
||||
]
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn sets_are_newest_first_with_undated_last() {
|
||||
let got = parse_sets(SETS_FIXTURE).unwrap();
|
||||
let codes: Vec<&str> = 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");
|
||||
}
|
||||
}
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod commands;
|
||||
mod news;
|
||||
mod state;
|
||||
mod sync;
|
||||
|
||||
@@ -48,7 +49,8 @@ fn sync_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||
|
||||
#[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,
|
||||
|
||||
347
src-tauri/src/news.rs
Normal file
347
src-tauri/src/news.rs
Normal file
@@ -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<AppState>>;
|
||||
|
||||
/// 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<SetInfo>,
|
||||
pub articles: Vec<Article>,
|
||||
/// 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<News> {
|
||||
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<News, String> {
|
||||
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<RawCard>,
|
||||
#[serde(default)]
|
||||
has_more: bool,
|
||||
#[serde(default)]
|
||||
next_page: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawCard {
|
||||
#[serde(default)]
|
||||
oracle_id: Option<String>,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
type_line: String,
|
||||
#[serde(default)]
|
||||
collector_number: String,
|
||||
#[serde(default)]
|
||||
cmc: f64,
|
||||
#[serde(default)]
|
||||
colors: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
color_identity: Vec<String>,
|
||||
#[serde(default)]
|
||||
produced_mana: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
legalities: std::collections::HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
prices: Option<Prices>,
|
||||
#[serde(default)]
|
||||
image_uris: Option<ImageUris>,
|
||||
#[serde(default)]
|
||||
card_faces: Option<Vec<Face>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Prices {
|
||||
#[serde(default)]
|
||||
usd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImageUris {
|
||||
#[serde(default)]
|
||||
normal: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Face {
|
||||
#[serde(default)]
|
||||
image_uris: Option<ImageUris>,
|
||||
#[serde(default)]
|
||||
type_line: Option<String>,
|
||||
#[serde(default)]
|
||||
colors: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// 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<Vec<SetCard>, String> {
|
||||
let _ = &app;
|
||||
let mut cards: Vec<RawCard> = Vec::new();
|
||||
let mut url: Option<String> = 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::<f64>().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();
|
||||
}
|
||||
}
|
||||
19
src/App.tsx
19
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) => (
|
||||
<svg
|
||||
@@ -116,6 +117,18 @@ const NAV: { id: Tab; label: string; icon: ReactNode }[] = [
|
||||
</>,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "news",
|
||||
label: "News",
|
||||
icon: svg(
|
||||
<>
|
||||
<path d="M4 5h11a1 1 0 0 1 1 1v13H5a1 1 0 0 1-1-1z" />
|
||||
<path d="M16 9h3a1 1 0 0 1 1 1v8a1 1 0 0 1-2 0V9" />
|
||||
<line x1="7" y1="9" x2="13" y2="9" />
|
||||
<line x1="7" y1="13" x2="13" y2="13" />
|
||||
</>,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
/** Mobile-only tab (see `.bottom-nav`) holding card-database management —
|
||||
@@ -833,6 +846,10 @@ export default function App() {
|
||||
<CardSearch key={status?.library_path} onOfferUndo={offerUndo} />
|
||||
) : tab === "decks" ? (
|
||||
<DeckView key={status?.library_path} refreshSignal={deckRefreshSignal} />
|
||||
) : tab === "news" ? (
|
||||
// Library-independent, so unlike the others it isn't keyed on the
|
||||
// document — switching libraries doesn't change the news.
|
||||
<NewsView />
|
||||
) : (
|
||||
// The per-install status page: card data, library sync, and the
|
||||
// collection summary. On mobile it's the only home for the
|
||||
|
||||
@@ -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 },
|
||||
|
||||
42
src/api.ts
42
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<SyncReport>("convert_legacy_library", { path }),
|
||||
|
||||
/** Set releases and article links. `refresh` forces a fetch past the cache. */
|
||||
getNews: (refresh = false) => invoke<News>("get_news", { refresh }),
|
||||
/** Every card in a set, annotated with how many you own. */
|
||||
getSetCards: (code: string) => invoke<SetCard[]>("get_set_cards", { code }),
|
||||
|
||||
searchCards: (query: string, limit = 20) =>
|
||||
invoke<CardRef[]>("search_cards", { query, limit }),
|
||||
|
||||
|
||||
121
src/styles.css
121
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;
|
||||
}
|
||||
|
||||
260
src/views/NewsView.tsx
Normal file
260
src/views/NewsView.tsx
Normal file
@@ -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<News | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const [openSet, setOpenSet] = useState<SetInfo | null>(null);
|
||||
const [cards, setCards] = useState<SetCard[] | null>(null);
|
||||
const [cardsError, setCardsError] = useState<string | null>(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 (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2 className="page-header-title">
|
||||
{openSet.name}
|
||||
{cards && <span className="badge">{cards.length}</span>}
|
||||
</h2>
|
||||
<div className="page-header-actions">
|
||||
<button onClick={() => setOpenSet(null)}>← Back to news</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hint">
|
||||
{formatDate(openSet.released_at)} · {openSet.set_type.replace(/_/g, " ")} · set code{" "}
|
||||
{openSet.code.toUpperCase()}
|
||||
</div>
|
||||
{cardsError ? (
|
||||
<p className="hint">{cardsError}</p>
|
||||
) : !cards ? (
|
||||
<p className="hint">Loading cards…</p>
|
||||
) : cards.length === 0 ? (
|
||||
<p className="hint">No cards listed for this set yet.</p>
|
||||
) : (
|
||||
<div className="tile-grid" style={{ marginTop: "var(--space-4)" }}>
|
||||
{cards.map((c) => (
|
||||
<CardTile
|
||||
key={c.oracle_id + c.collector_number}
|
||||
card={c}
|
||||
corner={
|
||||
<QtyStepper
|
||||
overlay
|
||||
value={c.quantity_owned}
|
||||
onDecrement={() => adjust(c.oracle_id, -1)}
|
||||
onIncrement={() => adjust(c.oracle_id, 1)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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 (
|
||||
<li
|
||||
key={s.code}
|
||||
className={`set-row ${upcoming ? "upcoming" : ""}`}
|
||||
onClick={() => 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. */}
|
||||
<span className="set-date">
|
||||
<span className="set-date-day">{top}</span>
|
||||
<span className="set-date-year">{bottom}</span>
|
||||
</span>
|
||||
{s.icon_uri ? (
|
||||
<img className="set-icon" src={s.icon_uri} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="set-icon" />
|
||||
)}
|
||||
<span className="set-row-main">
|
||||
<span className="set-name">{s.name}</span>
|
||||
<span className="set-row-meta">
|
||||
{s.set_type.replace(/_/g, " ")} · {s.card_count.toLocaleString()} cards
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2 className="page-header-title">News</h2>
|
||||
<div className="page-header-actions">
|
||||
<button onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="hint">{error}</p>}
|
||||
{news && (
|
||||
<div className="hint">
|
||||
Updated {formatAge(news.fetched_at)}
|
||||
{news.fetched_at === 0 && " — check your connection"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!news && !error && <p className="hint">Loading…</p>}
|
||||
|
||||
{news && (
|
||||
<div className="news-columns">
|
||||
<section className="news-col">
|
||||
<h3 className="group-header">Releases</h3>
|
||||
{news.sets.length === 0 ? (
|
||||
<p className="hint">Couldn't load set releases.</p>
|
||||
) : (
|
||||
<>
|
||||
{upcoming.length > 0 && (
|
||||
<>
|
||||
<div className="section-label">Upcoming</div>
|
||||
<ul className="result-list set-list">{upcoming.map((s) => setRow(s, true))}</ul>
|
||||
</>
|
||||
)}
|
||||
<div className="section-label" style={{ marginTop: "var(--space-4)" }}>
|
||||
Released
|
||||
</div>
|
||||
<ul className="result-list set-list">{shown.map((s) => setRow(s))}</ul>
|
||||
{released.length > shown.length && (
|
||||
<div className="row" style={{ marginTop: "var(--space-3)" }}>
|
||||
<button onClick={() => setShowAll(true)}>
|
||||
Show all {released.length} sets
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="news-col">
|
||||
<h3 className="group-header">Latest from Wizards</h3>
|
||||
{/* This half has no supported feed, so it's expected to break
|
||||
eventually. Say so plainly and leave releases alone. */}
|
||||
{news.articles.length === 0 ? (
|
||||
<p className="hint">
|
||||
{news.articles_ok
|
||||
? "No articles found."
|
||||
: "Couldn't load the latest news right now."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{!news.articles_ok && (
|
||||
<p className="hint">Showing the last articles we could load.</p>
|
||||
)}
|
||||
<ul className="result-list article-list">
|
||||
{news.articles.map((a) => (
|
||||
<li key={a.url} onClick={() => openUrl(a.url)} title={a.url}>
|
||||
{/* Fixed-width so titles line up; the categories are a
|
||||
small closed set, not free text. */}
|
||||
<span className="article-cat">{a.category.replace(/-/g, " ")}</span>
|
||||
<span className="article-title">{a.title}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="hint">Articles open on magic.wizards.com.</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user