Skip to main content

launcher/
news.rs

1use crate::constants::{CODEBERG_API_BASE_URL, CODEBERG_REPO};
2use crate::launcher_config::LauncherConfig;
3use base64::Engine;
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use std::sync::mpsc::{self, Receiver};
8
9// ---------------------------------------------------------------------------
10// Forgejo API shapes
11// ---------------------------------------------------------------------------
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct WikiCommitAuthor {
15    pub name: String,
16    pub email: String,
17    pub date: String,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct WikiCommit {
22    pub sha: String,
23    pub author: WikiCommitAuthor,
24    pub message: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct WikiPageHeader {
29    pub title: String,
30    pub html_url: String,
31    pub sub_url: String,
32    pub last_commit: WikiCommit,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct WikiPageDetail {
37    pub title: String,
38    pub html_url: String,
39    pub sub_url: String,
40    pub last_commit: WikiCommit,
41    pub content_base64: String,
42}
43
44// ---------------------------------------------------------------------------
45// Cached article
46// ---------------------------------------------------------------------------
47
48/// A news article stored in the local cache.
49///
50/// The `slug` field (`YYYY-MM-DD_article-name`) is derived from the wiki page
51/// title (`news/YYYY-MM-DD_article-name`) and is the canonical, stable
52/// identifier. It does **not** change when the article is edited, so it is
53/// safe to compare against `last_read_news`.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct CachedArticle {
56    /// `YYYY-MM-DD_slug`, canonical article identifier, stable across edits.
57    pub slug: String,
58    /// `YYYY-MM-DD`, publication date, derived from the slug, used for display.
59    pub date: String,
60    pub title: String,
61    pub html_url: String,
62    pub sub_url: String,
63    /// Current commit SHA; compared against the API to detect content updates.
64    pub sha: String,
65    pub author_name: String,
66    pub content: String,
67}
68
69// ---------------------------------------------------------------------------
70// Channel message
71// ---------------------------------------------------------------------------
72
73pub enum NewsStatus {
74    Checking,
75    Success {
76        articles: Vec<CachedArticle>,
77        badge_count: usize,
78        /// RFC 3339 timestamp to persist as `last_news_check`.
79        last_news_check: String,
80    },
81    Error(String),
82}
83
84// ---------------------------------------------------------------------------
85// NewsManager
86// ---------------------------------------------------------------------------
87
88pub struct NewsManager {
89    receiver: Option<Receiver<NewsStatus>>,
90    is_checking: bool,
91    articles: Vec<CachedArticle>,
92    badge_count: usize,
93}
94
95impl NewsManager {
96    /// Create a new `NewsManager`, loading whatever articles are already
97    /// cached in the user-profile cache directory.
98    #[must_use]
99    pub fn new(game_path: &std::path::Path, config: &LauncherConfig) -> Self {
100        // One-time migration from the old in-game cache location.
101        let old_cache_dir = game_path.join("Saved_Data/news_cache");
102        let new_cache_dir = news_cache_dir();
103        // Still the shipped behavior; the allow comes off with the deprecated fn.
104        #[allow(deprecated)]
105        migrate_cache(&old_cache_dir, &new_cache_dir);
106
107        let articles = load_cached_articles(&new_cache_dir);
108        let badge_count = count_new_articles(&articles, config.news.last_read.as_deref());
109
110        Self {
111            receiver: None,
112            is_checking: false,
113            articles,
114            badge_count,
115        }
116    }
117
118    #[must_use]
119    pub fn articles(&self) -> &[CachedArticle] {
120        &self.articles
121    }
122
123    #[must_use]
124    pub fn badge_count(&self) -> usize {
125        self.badge_count
126    }
127
128    #[must_use]
129    pub fn is_checking(&self) -> bool {
130        self.is_checking
131    }
132
133    /// Mark all articles as read and persist the change to the Launcher Config.
134    pub fn clear_badge(&mut self, config: &mut LauncherConfig) {
135        if self.badge_count > 0 {
136            self.badge_count = 0;
137            if let Some(newest) = self.articles.first() {
138                config.news.last_read = Some(newest.slug.clone());
139                if let Err(e) = config.save() {
140                    eprintln!("Failed to save launcher config after clearing badge: {e}");
141                }
142            }
143        }
144    }
145
146    /// Returns `true` if a news check is due (no check in the last 24h).
147    #[must_use]
148    pub fn is_check_due(&self, config: &LauncherConfig) -> bool {
149        if let Some(ref ts) = config.news.last_check
150            && let Ok(dt) = ts.parse::<chrono::DateTime<Utc>>()
151        {
152            Utc::now().signed_duration_since(dt).num_hours() >= 24
153        } else {
154            true
155        }
156    }
157
158    /// Poll the background thread. Returns the latest status if one arrived.
159    ///
160    /// The caller is responsible for acting on `NewsStatus::Success` to update
161    /// `LauncherConfig.news.last_check` and save.
162    pub fn poll(&mut self) -> Option<NewsStatus> {
163        let mut last_status = None;
164        if let Some(rx) = &self.receiver {
165            while let Ok(status) = rx.try_recv() {
166                last_status = Some(status);
167            }
168        }
169
170        if let Some(ref status) = last_status {
171            match status {
172                NewsStatus::Checking => {
173                    self.is_checking = true;
174                }
175                NewsStatus::Success {
176                    articles,
177                    badge_count,
178                    ..
179                } => {
180                    self.articles = articles.clone();
181                    self.badge_count = *badge_count;
182                    self.is_checking = false;
183                    self.receiver = None;
184                }
185                NewsStatus::Error(_) => {
186                    self.is_checking = false;
187                    self.receiver = None;
188                }
189            }
190        }
191        last_status
192    }
193
194    /// Spawn a background thread to check for new articles.
195    pub fn start_check(&mut self, last_read_slug: &Option<String>) {
196        let (tx, rx) = mpsc::channel();
197        self.receiver = Some(rx);
198        self.is_checking = true;
199
200        let tx_thread = tx.clone();
201        let _ = tx.send(NewsStatus::Checking);
202
203        let cache_dir = news_cache_dir();
204        let last_read_slug = last_read_slug.clone();
205        std::thread::spawn(move || match do_news_check(cache_dir, &last_read_slug) {
206            Ok((articles, badge_count, last_news_check)) => {
207                let _ = tx_thread.send(NewsStatus::Success {
208                    articles,
209                    badge_count,
210                    last_news_check,
211                });
212            }
213            Err(e) => {
214                let _ = tx_thread.send(NewsStatus::Error(e.to_string()));
215            }
216        });
217    }
218}
219
220// ---------------------------------------------------------------------------
221// Cache directory
222// ---------------------------------------------------------------------------
223
224/// Returns the shared news cache directory in the user's profile.
225///
226/// `%APPDATA%\Supreme-Snowboarding-Community-Launcher\news_cache` on Windows,
227/// `~/Library/Application Support/Supreme-Snowboarding-Community-Launcher/news_cache`
228/// on macOS, `$XDG_DATA_HOME/Supreme-Snowboarding-Community-Launcher/news_cache` on Linux.
229///
230/// Falls back to `./news_cache` if the OS provides no data directory.
231#[must_use]
232fn news_cache_dir() -> PathBuf {
233    dirs::data_dir()
234        .unwrap_or_else(|| PathBuf::from("."))
235        .join("Supreme-Snowboarding-Community-Launcher")
236        .join("news_cache")
237}
238
239// ---------------------------------------------------------------------------
240// Migration
241// ---------------------------------------------------------------------------
242
243/// One-time migration: move cached article files from the old in-game location
244/// to the new user-profile location, then remove the old directory if empty.
245///
246#[deprecated(
247    since = "1.7.0",
248    note = "Old cache location will be removed anytime after 2026-08"
249)]
250fn migrate_cache(old_dir: &std::path::Path, new_dir: &std::path::Path) {
251    if !old_dir.exists() {
252        return;
253    }
254
255    // Ensure the target exists before trying to move files into it.
256    if std::fs::create_dir_all(new_dir).is_err() {
257        return;
258    }
259
260    if let Ok(entries) = std::fs::read_dir(old_dir) {
261        for entry in entries.flatten() {
262            let src = entry.path();
263            if src.extension().is_some_and(|e| e == "json")
264                && let Some(filename) = src.file_name()
265            {
266                let dst = new_dir.join(filename);
267                // Only copy if the destination doesn't already exist.
268                if !dst.exists()
269                    && let Err(e) = std::fs::copy(&src, &dst)
270                {
271                    eprintln!(
272                        "Failed to migrate cached article {} to {}: {e}",
273                        src.display(),
274                        dst.display()
275                    );
276                };
277                // Unconditionnaly remove the source, as losing the file contents
278                // is acceptable.
279                if let Err(e) = std::fs::remove_file(&src) {
280                    eprintln!("Failed to remove old cache file {}: {e}", src.display());
281                }
282            }
283        }
284    }
285
286    // Remove the old directory with all its contents, even if it should be
287    // empty. There is no expected data of value in it.
288    if let Err(e) = std::fs::remove_dir_all(old_dir) {
289        eprintln!(
290            "Failed to remove old cache firectory {}: {e}",
291            old_dir.display()
292        );
293    }
294}
295
296// ---------------------------------------------------------------------------
297// Slug / date helpers
298// ---------------------------------------------------------------------------
299
300/// Extract `(slug, date)` from a wiki page title of the form
301/// `news/YYYY-MM-DD_some-article-name`.
302///
303/// Returns `None` if the title does not match the expected pattern.
304fn parse_slug_and_date(title: &str) -> Option<(String, String)> {
305    let rest = title.strip_prefix("news/")?;
306    // The date is the first 10 characters: YYYY-MM-DD
307    if rest.len() < 10 {
308        return None;
309    }
310    let (date_part, _) = rest.split_at(10);
311    // Validate it looks like a date (digits and dashes only).
312    if !date_part.chars().all(|c| c.is_ascii_digit() || c == '-') {
313        return None;
314    }
315    Some((rest.to_string(), date_part.to_string()))
316}
317
318// ---------------------------------------------------------------------------
319// Badge counting
320// ---------------------------------------------------------------------------
321
322/// Count articles whose slug is lexicographically greater than `last_read_slug`.
323///
324/// Because slugs start with `YYYY-MM-DD`, lexicographic order equals
325/// chronological order, making this comparison safe and edit-stable.
326fn count_new_articles(articles: &[CachedArticle], last_read_slug: Option<&str>) -> usize {
327    match last_read_slug {
328        None => articles.len(),
329        Some(last) => articles.iter().filter(|a| a.slug.as_str() > last).count(),
330    }
331}
332
333// ---------------------------------------------------------------------------
334// Cache I/O
335// ---------------------------------------------------------------------------
336
337fn load_cached_articles(cache_dir: &Path) -> Vec<CachedArticle> {
338    let mut articles = Vec::new();
339    if let Ok(entries) = std::fs::read_dir(cache_dir) {
340        for entry in entries.flatten() {
341            let path = entry.path();
342            if path.extension().is_some_and(|ext| ext == "json")
343                && let Ok(content) = std::fs::read_to_string(&path)
344                && let Ok(article) = serde_json::from_str::<CachedArticle>(&content)
345            {
346                articles.push(article);
347            }
348        }
349    }
350    // Most-recent first (slug = YYYY-MM-DD_... sorts lexicographically).
351    articles.sort_by(|a, b| b.slug.cmp(&a.slug));
352    articles
353}
354
355fn save_cached_article(cache_dir: &Path, article: &CachedArticle) -> std::io::Result<()> {
356    std::fs::create_dir_all(cache_dir)?;
357    let filename = format!("{}.json", article.slug);
358    let path = cache_dir.join(filename);
359    let content = serde_json::to_string_pretty(article)?;
360    std::fs::write(path, content)?;
361    Ok(())
362}
363
364// ---------------------------------------------------------------------------
365// Network check
366// ---------------------------------------------------------------------------
367
368fn do_news_check(
369    cache_dir: PathBuf,
370    last_read_slug: &Option<String>,
371) -> Result<(Vec<CachedArticle>, usize, String), Box<dyn std::error::Error + Send + Sync>> {
372    let list_url = if cfg!(feature = "local") {
373        format!("{CODEBERG_API_BASE_URL}/mock_wiki_pages.php")
374    } else {
375        format!("{CODEBERG_API_BASE_URL}/repos/{CODEBERG_REPO}/wiki/pages")
376    };
377
378    let client = ureq::Agent::new_with_config(
379        ureq::Agent::config_builder()
380            .user_agent("SupremeSnowboardingLauncher")
381            .timeout_connect(Some(std::time::Duration::from_secs(10)))
382            .timeout_global(Some(std::time::Duration::from_secs(300)))
383            .build(),
384    );
385
386    let response = client.get(&list_url).call()?;
387    if !response.status().is_success() {
388        return Err(format!("Failed to fetch wiki pages: status {}", response.status()).into());
389    }
390
391    let headers: Vec<WikiPageHeader> = response.into_body().read_json()?;
392
393    // Keep only well-formed news pages, sorted most-recent first.
394    let mut news_headers: Vec<(WikiPageHeader, String /*slug*/, String /*date*/)> = headers
395        .into_iter()
396        .filter_map(|h| parse_slug_and_date(&h.title).map(|(slug, date)| (h, slug, date)))
397        .collect();
398    news_headers.sort_by(|a, b| b.1.cmp(&a.1));
399
400    // Only fetch/cache the most recent article automatically.
401    news_headers.truncate(1);
402
403    // Load the full cache so all previously downloaded articles can be returned.
404    let mut articles = load_cached_articles(&cache_dir);
405
406    for (header, slug, date) in news_headers {
407        // Check if we already have this exact commit cached.
408        let already_cached = articles
409            .iter()
410            .any(|a| a.slug == slug && a.sha == header.last_commit.sha);
411
412        if !already_cached {
413            let detail_url = if cfg!(feature = "local") {
414                format!(
415                    "{CODEBERG_API_BASE_URL}/mock_wiki_page.php?page={}",
416                    header.sub_url
417                )
418            } else {
419                format!(
420                    "{CODEBERG_API_BASE_URL}/repos/{CODEBERG_REPO}/wiki/page/{}",
421                    header.sub_url
422                )
423            };
424
425            let detail_resp = client.get(&detail_url).call()?;
426            if detail_resp.status().is_success() {
427                let detail: WikiPageDetail = detail_resp.into_body().read_json()?;
428
429                let clean_b64: String = detail
430                    .content_base64
431                    .chars()
432                    .filter(|c| !c.is_whitespace())
433                    .collect();
434                let decoded_bytes = base64::prelude::BASE64_STANDARD.decode(&clean_b64)?;
435                let content_str = String::from_utf8(decoded_bytes)?;
436
437                let article = CachedArticle {
438                    slug: slug.clone(),
439                    date,
440                    title: detail.title,
441                    html_url: detail.html_url,
442                    sub_url: detail.sub_url,
443                    sha: detail.last_commit.sha,
444                    author_name: detail.last_commit.author.name,
445                    content: content_str,
446                };
447
448                if let Err(e) = save_cached_article(&cache_dir, &article) {
449                    eprintln!("Failed to cache article {slug}: {e}");
450                }
451
452                // Replace any stale cached entry for this slug, or add new.
453                if let Some(pos) = articles.iter().position(|a| a.slug == slug) {
454                    articles[pos] = article;
455                } else {
456                    articles.push(article);
457                }
458            }
459        }
460    }
461
462    articles.sort_by(|a, b| b.slug.cmp(&a.slug));
463
464    let badge_count = count_new_articles(&articles, last_read_slug.as_deref());
465    let last_news_check = Utc::now().to_rfc3339();
466
467    Ok((articles, badge_count, last_news_check))
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use chrono::Duration;
474
475    #[test]
476    fn test_is_check_due_when_none() {
477        let manager = NewsManager {
478            receiver: None,
479            is_checking: false,
480            articles: vec![],
481            badge_count: 0,
482        };
483        let config = LauncherConfig::default();
484        assert!(manager.is_check_due(&config));
485    }
486
487    #[test]
488    fn test_is_check_due_recent() {
489        let manager = NewsManager {
490            receiver: None,
491            is_checking: false,
492            articles: vec![],
493            badge_count: 0,
494        };
495        let mut config = LauncherConfig::default();
496        let recent = Utc::now() - Duration::hours(1);
497        config.news.last_check = Some(recent.to_rfc3339());
498        assert!(!manager.is_check_due(&config));
499    }
500
501    #[test]
502    fn test_is_check_due_expired() {
503        let manager = NewsManager {
504            receiver: None,
505            is_checking: false,
506            articles: vec![],
507            badge_count: 0,
508        };
509        let mut config = LauncherConfig::default();
510        let old = Utc::now() - Duration::hours(25);
511        config.news.last_check = Some(old.to_rfc3339());
512        assert!(manager.is_check_due(&config));
513    }
514
515    #[test]
516    fn test_is_check_due_invalid_timestamp() {
517        let manager = NewsManager {
518            receiver: None,
519            is_checking: false,
520            articles: vec![],
521            badge_count: 0,
522        };
523        let mut config = LauncherConfig::default();
524        config.news.last_check = Some("invalid-timestamp".to_string());
525        assert!(manager.is_check_due(&config));
526    }
527}