Files
OneCAD-Draft/src/discussions.rs
T

238 lines
7.8 KiB
Rust

//! 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<Vec<DiscussionEntry>, String> {
let mut entries: Vec<DiscussionEntry> =
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<std::path::PathBuf> {
crate::config::config_dir().map(|dir| dir.join("discussions.json"))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn load_cached() -> Vec<DiscussionEntry> {
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<Vec<DiscussionEntry>, 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<String, String> {
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<DiscussionEntry> {
feed.split("<entry>")
.skip(1)
.filter_map(|tail| {
let entry = tail.split_once("</entry>")?.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("<author>")?.1.split_once("</author>")?.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<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let value = block.split_once(&open)?.1.split_once(&close)?.0;
let compact = value.split_whitespace().collect::<Vec<_>>().join(" ");
if compact.is_empty() {
None
} else {
Some(
compact
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&"),
)
}
}
#[cfg(not(target_arch = "wasm32"))]
fn pinned_numbers(page: &str) -> std::collections::HashSet<u64> {
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("</ul>")
.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::<String>();
if let Ok(number) = digits.parse() {
numbers.insert(number);
}
rest = tail;
}
numbers
}
#[cfg(target_arch = "wasm32")]
pub async fn fetch_discussions_web() -> Result<Vec<DiscussionEntry>, 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#"
<entry>
<link type="text/html" rel="alternate" href="https://github.com/HakanSeven12/OpenCADStudio/discussions/2"/>
<title>New &amp; recent</title>
<updated>2026-07-28T10:00:00+00:00</updated>
<author><name>alice</name></author>
</entry>
<entry>
<link type="text/html" rel="alternate" href="https://github.com/HakanSeven12/OpenCADStudio/discussions/1"/>
<title>Pinned</title>
<updated>2026-07-20T10:00:00+00:00</updated>
<author><name>bob</name></author>
</entry>
"#;
let page = r#"
<h2 id="pinned-discussions">Pinned Discussions</h2>
<ul><a href="/HakanSeven12/OpenCADStudio/discussions/1">Pinned</a></ul>
"#;
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");
}
}