Add GitHub metadata enrichment for catalog apps

Enrich catalog apps with GitHub API data (stars, version, downloads,
release date) via two strategies: background drip for repo-level info
and on-demand fetch when opening a detail page.

- Add github_enrichment module with API calls, asset filtering, and
  architecture auto-detection for AppImage downloads
- DB migrations v14/v15 for GitHub metadata and release asset columns
- Extract github_owner/repo from feed links during catalog sync
- Display colored stat cards (stars, version, downloads, released) on
  detail pages with on-demand enrichment
- Show stars and version on browse tiles and featured carousel cards
- Replace install button with SplitButton dropdown when multiple arch
  assets available, preferring detected architecture
- Disable install button until enrichment completes to prevent stale
  AppImageHub URL downloads
- Keep enrichment banner visible on catalog page until truly complete,
  showing paused state when rate-limited
- Add GitHub token and auto-enrich toggle to preferences
This commit is contained in:
lashman
2026-02-28 16:49:13 +02:00
parent 92c51dc39e
commit f89aafca6a
15 changed files with 3027 additions and 224 deletions

338
src/ui/catalog_tile.rs Normal file
View File

@@ -0,0 +1,338 @@
use gtk::prelude::*;
use crate::core::database::CatalogApp;
use super::widgets;
/// Build a catalog tile for the browse grid.
/// Left-aligned layout: icon (48px) at top, name, description, category badge.
/// Card fills its entire FlowBoxChild cell.
pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.halign(gtk::Align::Fill)
.valign(gtk::Align::Fill)
.hexpand(true)
.vexpand(true)
.build();
card.add_css_class("card");
card.add_css_class("catalog-tile");
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.margin_top(14)
.margin_bottom(14)
.margin_start(14)
.margin_end(14)
.vexpand(true)
.build();
// Icon (48px) - left aligned
let icon = widgets::app_icon(None, &app.name, 48);
icon.add_css_class("icon-dropshadow");
icon.set_halign(gtk::Align::Start);
inner.append(&icon);
// App name - left aligned
let name_label = gtk::Label::builder()
.label(&app.name)
.css_classes(["heading"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(20)
.xalign(0.0)
.halign(gtk::Align::Start)
.build();
inner.append(&name_label);
// Description (always 2 lines for uniform height)
let plain = app.description.as_deref()
.filter(|d| !d.is_empty())
.map(|d| strip_html(d))
.unwrap_or_default();
let snippet: String = plain.chars().take(80).collect();
let text = if plain.is_empty() {
// Non-breaking space placeholder to reserve 2 lines
"\u{00a0}".to_string()
} else if snippet.len() < plain.len() {
format!("{}...", snippet.trim_end())
} else {
snippet
};
let desc_label = gtk::Label::builder()
.label(&text)
.css_classes(["caption", "dim-label"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.lines(2)
.wrap(true)
.xalign(0.0)
.max_width_chars(24)
.halign(gtk::Align::Start)
.build();
// Force 2-line height
desc_label.set_height_request(desc_label.preferred_size().1.height().max(36));
inner.append(&desc_label);
// Stats row (stars + version) - only if data exists
let has_stars = app.github_stars.is_some_and(|s| s > 0);
let has_version = app.latest_version.is_some();
if has_stars || has_version {
let stats_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.halign(gtk::Align::Start)
.build();
stats_row.add_css_class("catalog-stats-row");
if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
let star_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
let star_icon = gtk::Image::from_icon_name("starred-symbolic");
star_icon.set_pixel_size(12);
star_box.append(&star_icon);
let star_label = gtk::Label::new(Some(&widgets::format_count(stars)));
star_box.append(&star_label);
stats_row.append(&star_box);
}
if let Some(ref ver) = app.latest_version {
let ver_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
let ver_icon = gtk::Image::from_icon_name("tag-symbolic");
ver_icon.set_pixel_size(12);
ver_box.append(&ver_icon);
let ver_label = gtk::Label::builder()
.label(ver.as_str())
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(12)
.build();
ver_box.append(&ver_label);
stats_row.append(&ver_box);
}
inner.append(&stats_row);
}
// Category badge - left aligned
if let Some(ref cats) = app.categories {
let first_cat: String = cats.split(';')
.next()
.or_else(|| cats.split(',').next())
.unwrap_or("")
.trim()
.to_string();
if !first_cat.is_empty() {
let badge = widgets::status_badge(&first_cat, "neutral");
badge.set_halign(gtk::Align::Start);
badge.set_margin_top(2);
inner.append(&badge);
}
}
card.append(&inner);
let child = gtk::FlowBoxChild::builder()
.child(&card)
.build();
child.add_css_class("activatable");
child
}
/// Build a featured banner card for the carousel.
/// Layout: screenshot preview on top, then icon + name + description + badge below.
/// Width is set dynamically by the carousel layout.
pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
.halign(gtk::Align::Fill)
.valign(gtk::Align::Fill)
.hexpand(true)
.vexpand(true)
.build();
card.add_css_class("card");
card.add_css_class("catalog-featured-card");
card.add_css_class("activatable");
card.set_widget_name(&format!("featured-{}", app.id));
// Screenshot preview area (top)
let screenshot_frame = gtk::Frame::new(None);
screenshot_frame.add_css_class("catalog-featured-screenshot");
screenshot_frame.set_height_request(160);
screenshot_frame.set_hexpand(true);
// Spinner placeholder until image loads
let spinner = gtk::Spinner::builder()
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.spinning(true)
.width_request(32)
.height_request(32)
.build();
screenshot_frame.set_child(Some(&spinner));
card.append(&screenshot_frame);
// Info section below screenshot
let info_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.margin_top(10)
.margin_bottom(10)
.margin_start(12)
.margin_end(12)
.build();
// Icon (48px)
let icon = widgets::app_icon(None, &app.name, 48);
icon.add_css_class("icon-dropshadow");
icon.set_valign(gtk::Align::Start);
info_box.append(&icon);
// Text column
let text_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(2)
.valign(gtk::Align::Center)
.hexpand(true)
.build();
// App name
let name_label = gtk::Label::builder()
.label(&app.name)
.css_classes(["heading"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(28)
.xalign(0.0)
.halign(gtk::Align::Start)
.build();
text_box.append(&name_label);
// Description (1 line in featured since space is tight)
if let Some(ref desc) = app.description {
if !desc.is_empty() {
let plain = strip_html(desc);
let snippet: String = plain.chars().take(60).collect();
let text = if snippet.len() < plain.len() {
format!("{}...", snippet.trim_end())
} else {
snippet
};
let desc_label = gtk::Label::builder()
.label(&text)
.css_classes(["caption", "dim-label"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.lines(1)
.xalign(0.0)
.max_width_chars(35)
.halign(gtk::Align::Start)
.build();
text_box.append(&desc_label);
}
}
// Badge row: category + stars
let badge_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.margin_top(2)
.build();
if let Some(ref cats) = app.categories {
let first_cat: String = cats.split(';')
.next()
.or_else(|| cats.split(',').next())
.unwrap_or("")
.trim()
.to_string();
if !first_cat.is_empty() {
let badge = widgets::status_badge(&first_cat, "info");
badge.set_halign(gtk::Align::Start);
badge_row.append(&badge);
}
}
if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
let star_badge = widgets::status_badge_with_icon(
"starred-symbolic",
&widgets::format_count(stars),
"neutral",
);
star_badge.set_halign(gtk::Align::Start);
badge_row.append(&star_badge);
}
text_box.append(&badge_row);
info_box.append(&text_box);
card.append(&info_box);
card
}
/// Strip HTML tags from a string, returning plain text.
pub fn strip_html(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(ch),
_ => {}
}
}
// Collapse whitespace
let collapsed: String = result.split_whitespace().collect::<Vec<&str>>().join(" ");
// Decode common HTML entities
collapsed
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_html_basic() {
assert_eq!(strip_html("<p>Hello world</p>"), "Hello world");
}
#[test]
fn test_strip_html_nested() {
assert_eq!(
strip_html("<p>Hello <b>bold</b> world</p>"),
"Hello bold world"
);
}
#[test]
fn test_strip_html_entities() {
assert_eq!(strip_html("&amp; &lt; &gt; &quot;"), "& < > \"");
}
#[test]
fn test_strip_html_multiline() {
let input = "<p>Line one</p>\n<p>Line two</p>";
assert_eq!(strip_html(input), "Line one Line two");
}
#[test]
fn test_strip_html_list() {
let input = "<ul>\n <li>Item 1</li>\n <li>Item 2</li>\n</ul>";
assert_eq!(strip_html(input), "Item 1 Item 2");
}
#[test]
fn test_strip_html_plain_text() {
assert_eq!(strip_html("No HTML here"), "No HTML here");
}
}