forked from 1b-agent/opencadstudio-upstream
Formats the branch the way main's 9cf9ff2 did, so merging main only
conflicts where the code itself differs.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013RyGiMXcsf9HTKfqDxaQGt
237 lines
7.9 KiB
Rust
237 lines
7.9 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(""", "\"")
|
|
.replace("'", "'")
|
|
.replace("'", "'")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace("&", "&"),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[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 & 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");
|
|
}
|
|
}
|