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:
199
src/window.rs
199
src/window.rs
@@ -9,6 +9,7 @@ use crate::config::APP_ID;
|
||||
use crate::core::analysis;
|
||||
use crate::core::database::Database;
|
||||
use crate::core::discovery;
|
||||
use crate::core::footprint;
|
||||
use crate::core::integrator;
|
||||
use crate::core::launcher;
|
||||
use crate::core::notification;
|
||||
@@ -43,6 +44,7 @@ mod imp {
|
||||
pub drop_overlay: OnceCell<gtk::Box>,
|
||||
pub drop_revealer: OnceCell<gtk::Revealer>,
|
||||
pub watcher_handle: std::cell::RefCell<Option<notify::RecommendedWatcher>>,
|
||||
pub enrichment_banner: OnceCell<gtk::Box>,
|
||||
}
|
||||
|
||||
impl Default for DriftwoodWindow {
|
||||
@@ -57,6 +59,7 @@ mod imp {
|
||||
drop_overlay: OnceCell::new(),
|
||||
drop_revealer: OnceCell::new(),
|
||||
watcher_handle: std::cell::RefCell::new(None),
|
||||
enrichment_banner: OnceCell::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +102,22 @@ glib::wrapper! {
|
||||
gtk::Native, gtk::Root, gtk::ShortcutManager;
|
||||
}
|
||||
|
||||
/// Find a child widget by its widget name (breadth-first).
|
||||
fn find_child_by_name(parent: &impl IsA<gtk::Widget>, name: &str) -> Option<gtk::Widget> {
|
||||
let parent_widget = parent.upcast_ref::<gtk::Widget>();
|
||||
let mut child = parent_widget.first_child();
|
||||
while let Some(c) = child {
|
||||
if c.widget_name() == name {
|
||||
return Some(c);
|
||||
}
|
||||
if let Some(found) = find_child_by_name(&c, name) {
|
||||
return Some(found);
|
||||
}
|
||||
child = c.next_sibling();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn shortcut_row(accel: &str, description: &str) -> adw::ActionRow {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(description)
|
||||
@@ -166,8 +185,8 @@ impl DriftwoodWindow {
|
||||
let installed_nav = adw::NavigationView::new();
|
||||
installed_nav.push(&library_view.page);
|
||||
|
||||
// Catalog view
|
||||
let catalog_page = catalog_view::build_catalog_page(self.database());
|
||||
// Catalog view (has its own internal NavigationView for drill-down)
|
||||
let (catalog_nav, enrichment_banner) = catalog_view::build_catalog_page(self.database());
|
||||
|
||||
// Updates view
|
||||
let updates_toolbar = updates_view::build_updates_view(self.database());
|
||||
@@ -179,7 +198,7 @@ impl DriftwoodWindow {
|
||||
let installed_vs_page = view_stack.add_titled(&installed_nav, Some("installed"), &i18n("Installed"));
|
||||
installed_vs_page.set_icon_name(Some("view-grid-symbolic"));
|
||||
|
||||
let catalog_vs_page = view_stack.add_titled(&catalog_page, Some("catalog"), &i18n("Catalog"));
|
||||
let catalog_vs_page = view_stack.add_titled(&catalog_nav, Some("catalog"), &i18n("Catalog"));
|
||||
catalog_vs_page.set_icon_name(Some("system-software-install-symbolic"));
|
||||
|
||||
let updates_vs_page = view_stack.add_titled(&updates_toolbar, Some("updates"), &i18n("Updates"));
|
||||
@@ -481,6 +500,10 @@ impl DriftwoodWindow {
|
||||
if self.imp().library_view.set(library_view).is_err() {
|
||||
panic!("LibraryView already set");
|
||||
}
|
||||
self.imp()
|
||||
.enrichment_banner
|
||||
.set(enrichment_banner)
|
||||
.expect("EnrichmentBanner already set");
|
||||
|
||||
// Set up window actions
|
||||
self.setup_window_actions();
|
||||
@@ -1010,6 +1033,48 @@ impl DriftwoodWindow {
|
||||
}
|
||||
self.add_action(©_path_action);
|
||||
|
||||
// Uninstall action (from right-click context menu)
|
||||
let uninstall_action = gio::SimpleAction::new("uninstall-appimage", param_type);
|
||||
{
|
||||
let window_weak = self.downgrade();
|
||||
uninstall_action.connect_activate(move |_, param| {
|
||||
let Some(window) = window_weak.upgrade() else { return };
|
||||
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
|
||||
let db = window.database().clone();
|
||||
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
||||
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
||||
let fp = footprint::get_footprint(&db, record.id, record.size_bytes as u64);
|
||||
let fp_paths: Vec<(String, String, u64)> = fp.paths.iter()
|
||||
.filter(|p| p.exists)
|
||||
.map(|p| (
|
||||
p.path.to_string_lossy().to_string(),
|
||||
p.path_type.label().to_string(),
|
||||
p.size_bytes,
|
||||
))
|
||||
.collect();
|
||||
let is_integrated = record.integrated;
|
||||
let window_ref = window.clone();
|
||||
let db_refresh = db.clone();
|
||||
detail_view::show_uninstall_dialog_with_callback(
|
||||
&toast_overlay,
|
||||
&record,
|
||||
&db,
|
||||
is_integrated,
|
||||
&fp_paths,
|
||||
Some(Box::new(move || {
|
||||
// Refresh the library view after uninstall
|
||||
if let Some(lib_view) = window_ref.imp().library_view.get() {
|
||||
if let Ok(records) = db_refresh.get_all_appimages() {
|
||||
lib_view.populate(records);
|
||||
}
|
||||
}
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
self.add_action(&uninstall_action);
|
||||
|
||||
// View switching actions for keyboard shortcuts
|
||||
let show_installed_action = gio::SimpleAction::new("show-installed", None);
|
||||
{
|
||||
@@ -1185,6 +1250,134 @@ impl DriftwoodWindow {
|
||||
|
||||
// Update badge on Updates tab
|
||||
self.refresh_update_badge();
|
||||
|
||||
// Background GitHub enrichment
|
||||
self.start_background_enrichment();
|
||||
}
|
||||
|
||||
fn start_background_enrichment(&self) {
|
||||
let settings = self.settings().clone();
|
||||
if !settings.boolean("catalog-auto-enrich") {
|
||||
return;
|
||||
}
|
||||
|
||||
let db = self.database().clone();
|
||||
|
||||
// Check if there are unenriched apps
|
||||
let (enriched, total) = db.catalog_enrichment_progress().unwrap_or((0, 0));
|
||||
if total == 0 || enriched >= total {
|
||||
return;
|
||||
}
|
||||
|
||||
let banner = self.imp().enrichment_banner.get().cloned();
|
||||
let view_stack = self.imp().view_stack.get().cloned();
|
||||
|
||||
// Show banner initially
|
||||
if let Some(ref b) = banner {
|
||||
b.set_visible(true);
|
||||
if let Some(label) = find_child_by_name(b, "enrich-label") {
|
||||
if let Ok(l) = label.downcast::<gtk::Label>() {
|
||||
l.set_label(&format!("Enriching app data from GitHub ({}/{})...", enriched, total));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Channel for progress updates
|
||||
let (tx, rx) = std::sync::mpsc::channel::<(i64, i64, bool)>();
|
||||
|
||||
let token = settings.string("github-token").to_string();
|
||||
|
||||
// Background thread: runs batch enrichment
|
||||
glib::spawn_future_local(async move {
|
||||
let tx_c = tx.clone();
|
||||
|
||||
gio::spawn_blocking(move || {
|
||||
let bg_db = match crate::core::database::Database::open() {
|
||||
Ok(db) => db,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
loop {
|
||||
let result = crate::core::github_enrichment::background_enrich_batch(
|
||||
&bg_db,
|
||||
&token,
|
||||
20,
|
||||
&|done, total| {
|
||||
tx_c.send((done, total, false)).ok();
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((count, should_continue)) => {
|
||||
if count == 0 || !should_continue {
|
||||
// Done or rate limited
|
||||
if let Ok((done, total)) = bg_db.catalog_enrichment_progress() {
|
||||
tx_c.send((done, total, true)).ok();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Background enrichment error: {}", e);
|
||||
tx_c.send((0, 0, true)).ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}).await.ok();
|
||||
});
|
||||
|
||||
// Poll progress on the UI thread.
|
||||
// Timer keeps running until enrichment is truly complete (all apps enriched).
|
||||
// When rate-limited, shows a paused message but keeps the banner visible.
|
||||
let banner_ref = banner;
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(250), move || {
|
||||
while let Ok((done, total, finished)) = rx.try_recv() {
|
||||
if let Some(ref b) = banner_ref {
|
||||
if finished {
|
||||
if total == 0 || done >= total {
|
||||
// Truly done - all apps enriched (or error with no data)
|
||||
b.set_visible(false);
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
// Rate limited or paused - keep banner visible with updated text
|
||||
if let Some(spinner) = find_child_by_name(b, "enrich-spinner") {
|
||||
if let Ok(s) = spinner.downcast::<gtk::Spinner>() {
|
||||
s.set_spinning(false);
|
||||
}
|
||||
}
|
||||
if let Some(label) = find_child_by_name(b, "enrich-label") {
|
||||
if let Ok(l) = label.downcast::<gtk::Label>() {
|
||||
l.set_label(&format!(
|
||||
"Enriching paused - rate limit ({}/{} enriched)", done, total,
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Active progress update
|
||||
if let Some(label) = find_child_by_name(b, "enrich-label") {
|
||||
if let Ok(l) = label.downcast::<gtk::Label>() {
|
||||
l.set_label(&format!(
|
||||
"Enriching app data from GitHub ({}/{})...", done, total,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show banner only when on the catalog tab
|
||||
if let Some(ref vs) = view_stack {
|
||||
let on_catalog = vs.visible_child_name()
|
||||
.map(|n| n == "catalog")
|
||||
.unwrap_or(false);
|
||||
if let Some(ref b) = banner_ref {
|
||||
b.set_visible(on_catalog);
|
||||
}
|
||||
}
|
||||
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
fn trigger_scan(&self) {
|
||||
|
||||
Reference in New Issue
Block a user