//! GitHub Discussions shown on the Start page. //! //! Native builds read GitHub's public Atom feed and the public Discussions //! page, which exposes the repository's pinned-discussion section without an //! access token. The last successful result is cached for offline launches. //! Web builds read `discussions.json`, generated by the Pages workflow through //! GitHub's authenticated GraphQL API. pub const DISCUSSIONS_URL: &str = "https://github.com/HakanSeven12/OpenCADStudio/discussions"; #[cfg(not(target_arch = "wasm32"))] const FEED_URL: &str = "https://github.com/HakanSeven12/OpenCADStudio/discussions.atom"; #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct DiscussionEntry { pub number: u64, pub title: String, pub url: String, pub author: String, pub updated_at: String, #[serde(default)] pub pinned: bool, } fn sort_entries(entries: &mut [DiscussionEntry]) { entries.sort_by(|a, b| { b.pinned .cmp(&a.pinned) .then_with(|| b.updated_at.cmp(&a.updated_at)) .then_with(|| b.number.cmp(&a.number)) }); } fn parse_json(body: &str) -> Result, String> { let mut entries: Vec = serde_json::from_str(body).map_err(|e| e.to_string())?; entries.retain(|entry| { entry.number > 0 && !entry.title.trim().is_empty() && entry.url.starts_with("https://github.com/") }); sort_entries(&mut entries); Ok(entries) } #[cfg(not(target_arch = "wasm32"))] fn cache_path() -> Option { crate::config::config_dir().map(|dir| dir.join("discussions.json")) } #[cfg(not(target_arch = "wasm32"))] pub fn load_cached() -> Vec { cache_path() .and_then(|path| std::fs::read_to_string(path).ok()) .and_then(|body| parse_json(&body).ok()) .or_else(|| parse_json(include_str!("../web/discussions.json")).ok()) .unwrap_or_default() } #[cfg(not(target_arch = "wasm32"))] pub fn fetch_discussions() -> Result, String> { let agent = crate::network::agent(std::time::Duration::from_secs(15)); let feed = get_text(&agent, FEED_URL)?; let page = get_text(&agent, DISCUSSIONS_URL)?; let pinned = pinned_numbers(&page); let mut entries = parse_atom(&feed); if entries.is_empty() { return Err("GitHub Discussions feed returned no entries".into()); } for entry in &mut entries { entry.pinned = pinned.contains(&entry.number); } sort_entries(&mut entries); if let Some(path) = cache_path() { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } if let Ok(body) = serde_json::to_string(&entries) { let _ = std::fs::write(path, body); } } Ok(entries) } #[cfg(not(target_arch = "wasm32"))] fn get_text(agent: &ureq::Agent, url: &str) -> Result { agent .get(url) .header( "User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION")), ) .call() .map_err(|e| e.to_string())? .body_mut() .with_config() .limit(4 * 1024 * 1024) .read_to_string() .map_err(|e| e.to_string()) } #[cfg(not(target_arch = "wasm32"))] fn parse_atom(feed: &str) -> Vec { feed.split("") .skip(1) .filter_map(|tail| { let entry = tail.split_once("")?.0; let url_start = entry .find("href=\"https://github.com/HakanSeven12/OpenCADStudio/discussions/")? + "href=\"".len(); let url_tail = &entry[url_start..]; let url = url_tail.split_once('"')?.0.to_string(); let number = url.rsplit('/').next()?.parse().ok()?; let title = xml_text(entry, "title")?; let author_block = entry.split_once("")?.1.split_once("")?.0; let author = xml_text(author_block, "name").unwrap_or_default(); let updated_at = xml_text(entry, "updated").unwrap_or_default(); Some(DiscussionEntry { number, title, url, author, updated_at, pinned: false, }) }) .collect() } #[cfg(not(target_arch = "wasm32"))] fn xml_text(block: &str, tag: &str) -> Option { let open = format!("<{tag}>"); let close = format!(""); let value = block.split_once(&open)?.1.split_once(&close)?.0; let compact = value.split_whitespace().collect::>().join(" "); if compact.is_empty() { None } else { Some( compact .replace(""", "\"") .replace("'", "'") .replace("'", "'") .replace("<", "<") .replace(">", ">") .replace("&", "&"), ) } } #[cfg(not(target_arch = "wasm32"))] fn pinned_numbers(page: &str) -> std::collections::HashSet { let mut numbers = std::collections::HashSet::new(); let Some(after_heading) = page .split_once("id=\"pinned-discussions\"") .map(|(_, rest)| rest) else { return numbers; }; let section = after_heading .split_once("") .map(|(section, _)| section) .unwrap_or(after_heading); let marker = "/HakanSeven12/OpenCADStudio/discussions/"; let mut rest = section; while let Some(pos) = rest.find(marker) { let tail = &rest[pos + marker.len()..]; let digits = tail .chars() .take_while(|ch| ch.is_ascii_digit()) .collect::(); if let Ok(number) = digits.parse() { numbers.insert(number); } rest = tail; } numbers } #[cfg(target_arch = "wasm32")] pub async fn fetch_discussions_web() -> Result, String> { use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; let window = web_sys::window().ok_or("no window")?; let response = JsFuture::from(window.fetch_with_str("discussions.json")) .await .map_err(|_| "fetch failed")?; let response: web_sys::Response = response.dyn_into().map_err(|_| "not a Response")?; if !response.ok() { return Err(format!("HTTP {}", response.status())); } let text = JsFuture::from(response.text().map_err(|_| "text() unavailable")?) .await .map_err(|_| "body read failed")?; let body = text.as_string().ok_or("body is not a string")?; parse_json(&body) } #[cfg(test)] mod tests { use super::*; #[cfg(not(target_arch = "wasm32"))] #[test] fn pinned_discussions_are_sorted_first() { let feed = r#" New & recent 2026-07-28T10:00:00+00:00 alice Pinned 2026-07-20T10:00:00+00:00 bob "#; let page = r#"

Pinned Discussions

"#; let pinned = pinned_numbers(page); let mut entries = parse_atom(feed); for entry in &mut entries { entry.pinned = pinned.contains(&entry.number); } sort_entries(&mut entries); assert_eq!(entries[0].number, 1); assert_eq!(entries[1].title, "New & recent"); } }