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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct CachedArticle {
56 pub slug: String,
58 pub date: String,
60 pub title: String,
61 pub html_url: String,
62 pub sub_url: String,
63 pub sha: String,
65 pub author_name: String,
66 pub content: String,
67}
68
69pub enum NewsStatus {
74 Checking,
75 Success {
76 articles: Vec<CachedArticle>,
77 badge_count: usize,
78 last_news_check: String,
80 },
81 Error(String),
82}
83
84pub struct NewsManager {
89 receiver: Option<Receiver<NewsStatus>>,
90 is_checking: bool,
91 articles: Vec<CachedArticle>,
92 badge_count: usize,
93}
94
95impl NewsManager {
96 #[must_use]
99 pub fn new(game_path: &std::path::Path, config: &LauncherConfig) -> Self {
100 let old_cache_dir = game_path.join("Saved_Data/news_cache");
102 let new_cache_dir = news_cache_dir();
103 #[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 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 #[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 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 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#[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#[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 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 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 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 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
296fn parse_slug_and_date(title: &str) -> Option<(String, String)> {
305 let rest = title.strip_prefix("news/")?;
306 if rest.len() < 10 {
308 return None;
309 }
310 let (date_part, _) = rest.split_at(10);
311 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
318fn 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
333fn 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 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
364fn 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 let mut news_headers: Vec<(WikiPageHeader, String , String )> = 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 news_headers.truncate(1);
402
403 let mut articles = load_cached_articles(&cache_dir);
405
406 for (header, slug, date) in news_headers {
407 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 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}