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:
2026-08-07 23:35:50 -05:00
parent 8a868ca18a
commit ce2e556c52
16 changed files with 1829 additions and 33 deletions

View File

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

View File

@@ -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"
]
}

View File

@@ -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
View 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();
}
}