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:
338
src/ui/catalog_tile.rs
Normal file
338
src/ui/catalog_tile.rs
Normal 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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
#[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("& < > ""), "& < > \"");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user