feat(ui): add discussions and semantic theming
Generate web discussion snapshots in CI; native builds use the public feed and an offline cache.
This commit is contained in:
parent
4bf14baea4
commit
28d8246c68
16 changed files with 960 additions and 98 deletions
240
src/discussions.rs
Normal file
240
src/discussions.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
//! 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: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(std::time::Duration::from_secs(15)))
|
||||
.build()
|
||||
.into();
|
||||
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!("CARGO_PKG_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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue