From 4b939f044afa58ed55b59b111a88d29c484469bc Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 28 Feb 2026 20:33:40 +0200 Subject: [PATCH] Add AppImageHub.com OCS API as primary catalog source Integrate the Pling/OCS REST API (appimagehub.com) as the primary catalog source with richer metadata than the existing appimage.github.io feed. Backend: - Add OCS API fetch with pagination, lenient JSON deserializers for loosely typed numeric fields, and non-AppImage file filtering (.dmg, .exe, etc.) - Database migration v17 adds OCS-specific columns (ocs_id, downloads, score, typename, personid, description, summary, version, tags, etc.) - Deduplicate secondary source apps against OCS entries - Shrink OCS CDN icon URLs from 770x540 to 100x100 for faster loading - Clear stale screenshot and icon caches on sync - Extract GitHub repo links from OCS HTML descriptions - Add fetch_ocs_download_files() to get all version files for an app - Resolve fresh JWT download URLs per slot at install time Detail page: - Fetch OCS download files on page open and populate install SplitButton with version dropdown (newest first, filtered for AppImage only) - Show OCS metadata: downloads, score, author, typename, tags, comments, created/updated dates, architecture, filename, file size, MD5 - Prefer ocs_description (full HTML with features/changelog) over short summary for the About section - Add html_to_description() to preserve formatting (lists, paragraphs) - Remove redundant Download link from Links section - Escape ampersands in Pango markup subtitles (categories, typename, tags) Catalog view: - OCS source syncs first as primary, appimage.github.io as secondary - Featured apps consider OCS download counts alongside GitHub stars UI: - Add pulldown-cmark for GitHub README markdown rendering in detail pages - Add build_markdown_view() widget for rendered markdown content --- Cargo.lock | 41 ++ Cargo.toml | 3 + data/resources/style.css | 52 ++ src/core/catalog.rs | 671 ++++++++++++++++++++++++- src/core/database.rs | 446 ++++++++++++----- src/core/github_enrichment.rs | 71 ++- src/ui/app_card.rs | 1 + src/ui/catalog_detail.rs | 913 ++++++++++++++++++++++++++-------- src/ui/catalog_tile.rs | 118 ++++- src/ui/catalog_view.rs | 299 ++++++++--- src/ui/dashboard.rs | 1 + src/ui/detail_view.rs | 1 + src/ui/library_view.rs | 1 + src/ui/preferences.rs | 1 + src/ui/updates_view.rs | 1 + src/ui/widgets.rs | 191 +++++++ 16 files changed, 2394 insertions(+), 417 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9bc4082..eb93b2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,6 +559,7 @@ dependencies = [ "log", "notify", "notify-rust", + "pulldown-cmark", "quick-xml", "rusqlite", "serde", @@ -876,6 +877,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1804,6 +1814,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags 2.11.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quick-xml" version = "0.37.5" @@ -2335,12 +2364,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index 9f61955..537fee5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,5 +51,8 @@ notify-rust = "4" # File system watching (inotify) notify = "7" +# Markdown parsing (for GitHub README rendering) +pulldown-cmark = "0.12" + [build-dependencies] glib-build-tools = "0.22" diff --git a/data/resources/style.css b/data/resources/style.css index 45f64c5..bace028 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -175,6 +175,58 @@ button.flat:not(.pill):not(.suggested-action):not(.destructive-action) { min-height: 24px; } +/* ===== Category Filter Tiles ===== */ +.category-tile { + padding: 14px 18px; + min-height: 48px; + border-radius: 12px; + border: none; + font-weight: 600; + font-size: 0.9em; + color: white; +} + +.category-tile image { + color: white; + opacity: 0.9; +} + +/* Colored backgrounds per category */ +.cat-accent { background: alpha(@accent_bg_color, 0.7); } +.cat-purple { background: alpha(@purple_3, 0.65); } +.cat-red { background: alpha(@red_3, 0.6); } +.cat-green { background: alpha(@success_bg_color, 0.55); } +.cat-orange { background: alpha(@orange_3, 0.65); } +.cat-blue { background: alpha(@blue_3, 0.6); } +.cat-amber { background: alpha(@warning_bg_color, 0.6); } +.cat-neutral { background: alpha(@window_fg_color, 0.2); } + +/* Hover: intensify the background */ +.cat-accent:hover { background: alpha(@accent_bg_color, 0.85); } +.cat-purple:hover { background: alpha(@purple_3, 0.8); } +.cat-red:hover { background: alpha(@red_3, 0.75); } +.cat-green:hover { background: alpha(@success_bg_color, 0.7); } +.cat-orange:hover { background: alpha(@orange_3, 0.8); } +.cat-blue:hover { background: alpha(@blue_3, 0.75); } +.cat-amber:hover { background: alpha(@warning_bg_color, 0.75); } +.cat-neutral:hover { background: alpha(@window_fg_color, 0.3); } + +/* Checked: full-strength background + light border for emphasis */ +.cat-accent:checked { background: @accent_bg_color; } +.cat-purple:checked { background: @purple_3; } +.cat-red:checked { background: @red_3; } +.cat-green:checked { background: @success_bg_color; } +.cat-orange:checked { background: @orange_3; } +.cat-blue:checked { background: @blue_3; } +.cat-amber:checked { background: @warning_bg_color; } +.cat-neutral:checked { background: alpha(@window_fg_color, 0.45); } + +/* Focus indicator on the tile itself */ +flowboxchild:focus-visible .category-tile { + outline: 2px solid @accent_bg_color; + outline-offset: 2px; +} + /* ===== Catalog Tile Cards ===== */ .catalog-tile { border: 1px solid alpha(@window_fg_color, 0.12); diff --git a/src/core/catalog.rs b/src/core/catalog.rs index 822f95a..588ec0b 100644 --- a/src/core/catalog.rs +++ b/src/core/catalog.rs @@ -20,6 +20,7 @@ pub struct CatalogSource { #[derive(Debug, Clone, PartialEq)] pub enum CatalogType { AppImageHub, + OcsAppImageHub, GitHubSearch, Custom, } @@ -28,6 +29,7 @@ impl CatalogType { pub fn as_str(&self) -> &str { match self { Self::AppImageHub => "appimage-hub", + Self::OcsAppImageHub => "ocs-appimagehub", Self::GitHubSearch => "github-search", Self::Custom => "custom", } @@ -36,6 +38,7 @@ impl CatalogType { pub fn from_str(s: &str) -> Self { match s { "appimage-hub" => Self::AppImageHub, + "ocs-appimagehub" => Self::OcsAppImageHub, "github-search" => Self::GitHubSearch, _ => Self::Custom, } @@ -63,10 +66,138 @@ pub struct CatalogApp { /// Default AppImageHub registry URL. const APPIMAGEHUB_API_URL: &str = "https://appimage.github.io/feed.json"; +/// OCS API base URL for AppImageHub.com. +const OCS_API_URL: &str = "https://api.appimagehub.com/ocs/v1/content/data"; +const OCS_PAGE_SIZE: u32 = 100; + +// --- OCS API response types --- + +/// Deserialize a JSON value that may be a number, a numeric string, or an empty string. +/// The OCS API is loosely typed and sometimes returns "" instead of null for numeric fields. +fn deserialize_lenient_i64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + let v = serde_json::Value::deserialize(deserializer)?; + match &v { + serde_json::Value::Number(n) => Ok(n.as_i64()), + serde_json::Value::String(s) if s.is_empty() => Ok(None), + serde_json::Value::String(s) => Ok(s.parse().ok()), + serde_json::Value::Null => Ok(None), + _ => Ok(None), + } +} + +fn deserialize_lenient_i32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + let v = serde_json::Value::deserialize(deserializer)?; + match &v { + serde_json::Value::Number(n) => Ok(n.as_i64().map(|n| n as i32)), + serde_json::Value::String(s) if s.is_empty() => Ok(None), + serde_json::Value::String(s) => Ok(s.parse().ok()), + serde_json::Value::Null => Ok(None), + _ => Ok(None), + } +} + +#[derive(Debug, serde::Deserialize)] +struct OcsResponse { + data: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct OcsItem { + #[serde(deserialize_with = "deserialize_lenient_i64", default)] + id: Option, + #[serde(default)] + name: String, + description: Option, + summary: Option, + downloads: Option, + #[serde(deserialize_with = "deserialize_lenient_i32", default)] + score: Option, + typename: Option, + personid: Option, + version: Option, + tags: Option, + changed: Option, + created: Option, + previewpic1: Option, + previewpic2: Option, + previewpic3: Option, + previewpic4: Option, + previewpic5: Option, + previewpic6: Option, + smallpreviewpic1: Option, + downloadlink1: Option, + downloadname1: Option, + #[serde(deserialize_with = "deserialize_lenient_i64", default)] + downloadsize1: Option, + download_package_arch1: Option, + download_package_type1: Option, + downloadmd5sum1: Option, + detailpage: Option, + #[serde(deserialize_with = "deserialize_lenient_i64", default)] + comments: Option, +} + +/// Extra OCS-specific metadata not in the in-memory CatalogApp. +struct OcsExtra { + ocs_id: i64, + ocs_downloads: Option, + ocs_score: Option, + ocs_typename: Option, + ocs_personid: Option, + ocs_description: Option, + ocs_summary: Option, + ocs_version: Option, + ocs_tags: Option, + ocs_changed: Option, + ocs_preview_url: Option, + ocs_detailpage: Option, + ocs_created: Option, + ocs_downloadname: Option, + ocs_downloadsize: Option, + ocs_arch: Option, + ocs_md5sum: Option, + ocs_comments: Option, +} + +/// OCS download resolution response. +#[derive(Debug, serde::Deserialize)] +struct OcsDownloadResponse { + data: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct OcsDownloadItem { + #[serde(rename = "downloadlink")] + download_link: Option, +} + +/// A downloadable file from an OCS content item. +/// Each OCS item can have multiple download files (different versions). +#[derive(Debug, Clone)] +pub struct OcsDownloadFile { + pub slot: u32, + pub ocs_id: i64, + pub filename: String, + pub version: String, + pub size_kb: Option, + pub arch: Option, + pub pkg_type: Option, +} + /// Sync a catalog source - fetch the index and store entries in the database. /// Progress updates sent during catalog sync. #[derive(Debug, Clone)] pub enum SyncProgress { + /// Starting sync for a named source. + SourceStarted { name: String, source_index: u32, source_count: u32 }, /// Fetching the feed from the remote source. FetchingFeed, /// Feed fetched, total number of apps found. @@ -75,8 +206,10 @@ pub enum SyncProgress { CachingIcon { current: u32, total: u32, app_name: String }, /// Saving apps to the database. SavingApps { current: u32, total: u32 }, - /// Sync complete. + /// Single source sync complete. Done { total: u32 }, + /// All sources finished syncing. + AllDone, } pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result { @@ -90,6 +223,13 @@ pub fn sync_catalog_with_progress( ) -> Result { on_progress(SyncProgress::FetchingFeed); + match source.source_type { + CatalogType::OcsAppImageHub => { + return sync_ocs_catalog(db, source, on_progress); + } + _ => {} + } + let apps = match source.source_type { CatalogType::AppImageHub => fetch_appimage_hub()?, CatalogType::Custom => fetch_custom_catalog(&source.url)?, @@ -97,6 +237,7 @@ pub fn sync_catalog_with_progress( log::warn!("GitHub catalog search not yet implemented"); Vec::new() } + CatalogType::OcsAppImageHub => unreachable!(), }; let total = apps.len() as u32; @@ -107,9 +248,18 @@ pub fn sync_catalog_with_progress( log::info!("Cached {} catalog icons", icon_count); let source_id = source.id.ok_or(CatalogError::NoSourceId)?; + + // Build set of OCS app names for dedup (skip apps already in OCS source) + let ocs_names = get_ocs_source_names(db); + let mut count = 0u32; for app in &apps { + // Deduplicate: skip if this app name exists in the OCS source + if ocs_names.contains(&app.name.to_lowercase()) { + continue; + } + count += 1; on_progress(SyncProgress::SavingApps { current: count, total }); @@ -153,20 +303,54 @@ pub fn sync_catalog_with_progress( } /// Download an AppImage from the catalog to a local directory. +/// If `ocs_id` is provided, resolves a fresh download URL from the OCS API +/// (since OCS download links use JWT tokens that expire). pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result { + install_from_catalog_with_ocs(app, install_dir, None, 1) +} + +/// Download an AppImage, optionally resolving a fresh OCS download URL. +/// `ocs_id` - the OCS content ID; `ocs_slot` - the file slot number (1-based, default 1). +pub fn install_from_catalog_with_ocs( + app: &CatalogApp, + install_dir: &Path, + ocs_id: Option, + ocs_slot: u32, +) -> Result { fs::create_dir_all(install_dir).map_err(|e| CatalogError::Io(e.to_string()))?; - // Derive filename from URL - let filename = app.download_url + // For OCS apps, resolve a fresh download URL (JWT links expire) + let download_url = if let Some(id) = ocs_id { + match resolve_ocs_download(id, ocs_slot) { + Ok(url) => url, + Err(e) => { + log::warn!("Failed to resolve OCS download, falling back to stored URL: {}", e); + app.download_url.clone() + } + } + } else { + app.download_url.clone() + }; + + // Derive filename from URL (strip query params) + let url_path = download_url.split('?').next().unwrap_or(&download_url); + let filename = url_path .rsplit('/') .next() .unwrap_or("downloaded.AppImage"); - let dest = install_dir.join(filename); + // If filename doesn't look like an AppImage, use the app name + let filename = if filename.contains(".AppImage") || filename.ends_with(".appimage") { + filename.to_string() + } else { + format!("{}.AppImage", sanitize_filename(&app.name)) + }; - log::info!("Downloading {} to {}", app.download_url, dest.display()); + let dest = install_dir.join(&filename); - let response = ureq::get(&app.download_url) + log::info!("Downloading {} to {}", download_url, dest.display()); + + let response = ureq::get(&download_url) .call() .map_err(|e| CatalogError::Network(e.to_string()))?; @@ -195,6 +379,419 @@ pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result std::collections::HashSet { + let sources = db.get_catalog_sources().unwrap_or_default(); + for src in &sources { + if src.source_type == "ocs-appimagehub" { + return db.get_catalog_app_names_for_source(src.id).unwrap_or_default(); + } + } + std::collections::HashSet::new() +} + +/// Sync the OCS AppImageHub catalog (primary source). +fn sync_ocs_catalog( + db: &Database, + source: &CatalogSource, + on_progress: &dyn Fn(SyncProgress), +) -> Result { + let source_id = source.id.ok_or(CatalogError::NoSourceId)?; + let items = fetch_ocs_catalog(on_progress)?; + + let total = items.len() as u32; + on_progress(SyncProgress::FeedFetched { total }); + + let mut count = 0u32; + for (app, extra) in &items { + count += 1; + if count % 50 == 0 || count == total { + on_progress(SyncProgress::SavingApps { current: count, total }); + } + + let screenshots_str = if app.screenshots.is_empty() { + None + } else { + Some(app.screenshots.join(";")) + }; + + db.insert_ocs_catalog_app( + source_id, + &app.name, + extra.ocs_id, + extra.ocs_description.as_deref(), + extra.ocs_summary.as_deref(), + Some(app.categories.join(";")).as_deref().filter(|s| !s.is_empty()), + extra.ocs_version.as_deref(), + &app.download_url, + app.icon_url.as_deref(), + app.homepage.as_deref(), + screenshots_str.as_deref(), + extra.ocs_downloads, + extra.ocs_score, + extra.ocs_typename.as_deref(), + extra.ocs_personid.as_deref(), + extra.ocs_tags.as_deref(), + extra.ocs_changed.as_deref(), + extra.ocs_preview_url.as_deref(), + app.license.as_deref(), + extra.ocs_detailpage.as_deref(), + extra.ocs_created.as_deref(), + extra.ocs_downloadname.as_deref(), + extra.ocs_downloadsize, + extra.ocs_arch.as_deref(), + extra.ocs_md5sum.as_deref(), + extra.ocs_comments, + ).ok(); + + // Try to extract GitHub owner/repo from github_link (extracted from description), + // homepage, or download URL + let github_result = app.github_link.as_deref() + .and_then(|gl| github_enrichment::extract_github_repo(Some(gl), "")) + .or_else(|| github_enrichment::extract_github_repo( + app.homepage.as_deref(), + &app.download_url, + )); + if let Some((owner, repo)) = github_result { + if let Ok(Some(db_app)) = db.get_catalog_app_by_source_and_name(source_id, &app.name) { + db.update_catalog_app_github_repo(db_app, &owner, &repo).ok(); + } + } + } + + // Clear stale screenshot cache so new screenshot indices map correctly + clear_screenshot_cache(); + + // Clear and re-cache icons for OCS apps (URLs may have changed) + let ocs_apps: Vec = items.into_iter().map(|(a, _)| a).collect(); + clear_ocs_icon_cache(&ocs_apps); + let icon_count = cache_catalog_icons_with_progress(&ocs_apps, on_progress); + log::info!("Cached {} OCS catalog icons", icon_count); + + db.update_catalog_source_sync(source_id, count as i32).ok(); + on_progress(SyncProgress::Done { total: count }); + Ok(count) +} + +/// Fetch all apps from the OCS API, paginating through all pages. +fn fetch_ocs_catalog( + _on_progress: &dyn Fn(SyncProgress), +) -> Result, CatalogError> { + let mut all_items = Vec::new(); + let mut page = 0u32; + + loop { + let url = format!( + "{}?format=json&pagesize={}&page={}", + OCS_API_URL, OCS_PAGE_SIZE, page + ); + let response = ureq::get(&url) + .call() + .map_err(|e| CatalogError::Network(format!("OCS fetch page {} failed: {}", page, e)))?; + + let body = response.into_body().read_to_string() + .map_err(|e| CatalogError::Network(e.to_string()))?; + + let ocs_resp: OcsResponse = serde_json::from_str(&body) + .map_err(|e| CatalogError::Parse(format!("OCS JSON parse failed on page {}: {}", page, e)))?; + + if ocs_resp.data.is_empty() { + break; + } + + let page_count = ocs_resp.data.len(); + + for item in ocs_resp.data { + // Skip items with empty name, no id, or no download link + if item.name.trim().is_empty() { + continue; + } + let ocs_id = match item.id { + Some(id) => id, + None => continue, + }; + let download_url = match item.downloadlink1 { + Some(ref dl) if !dl.is_empty() => dl.clone(), + _ => continue, + }; + + // Skip non-AppImage downloads (e.g. .dmg, .exe, .rpm, .zip) + let pkg_type = item.download_package_type1.as_deref().unwrap_or(""); + if !pkg_type.is_empty() && pkg_type != "appimage" { + continue; + } + // Also check filename extension as fallback + if let Some(ref dname) = item.downloadname1 { + let lower = dname.to_lowercase(); + if lower.ends_with(".dmg") || lower.ends_with(".exe") + || lower.ends_with(".rpm") || lower.ends_with(".deb") + || lower.ends_with(".zip") || lower.ends_with(".msi") + || lower.ends_with(".pkg") || lower.ends_with(".7z") + { + continue; + } + } + + let ocs_downloads = item.downloads + .as_deref() + .and_then(|s| s.parse::().ok()); + + // previewpic1 is always the app icon/logo on appimagehub.com. + // Actual screenshots start from previewpic2 onward. + let screenshots: Vec = [ + item.previewpic2.as_deref(), + item.previewpic3.as_deref(), + item.previewpic4.as_deref(), + item.previewpic5.as_deref(), + item.previewpic6.as_deref(), + ] + .iter() + .filter_map(|p| p.filter(|s| !s.is_empty()).map(|s| s.to_string())) + .collect(); + + let categories = map_ocs_category(item.typename.as_deref().unwrap_or("")); + + // Extract GitHub link from HTML description if present + let github_link = item.description.as_deref() + .and_then(extract_github_link_from_html); + + // Use detailpage as homepage since OCS API has no homepage field + let homepage = item.detailpage.clone(); + + // Use smallpreviewpic1 as icon, fall back to previewpic1 (which is the icon/logo). + // Shrink from 770x540 to 100x100 since we only display at 48px. + let icon_url = item.smallpreviewpic1.clone() + .filter(|s| !s.is_empty()) + .or_else(|| item.previewpic1.clone().filter(|s| !s.is_empty())) + .map(|url| shrink_ocs_image_url(&url, "100x100")); + + let catalog_app = CatalogApp { + name: item.name.clone(), + description: item.summary.clone().or(item.description.clone()), + categories, + latest_version: item.version.clone(), + download_url, + icon_url, + homepage, + file_size: item.downloadsize1.map(|s| (s * 1024) as u64), // API gives KB, we store bytes + architecture: item.download_package_arch1.clone(), + screenshots, + license: None, + github_link, + }; + + let extra = OcsExtra { + ocs_id, + ocs_downloads, + ocs_score: item.score.map(|s| s as i64), + ocs_typename: item.typename, + ocs_personid: item.personid, + ocs_description: item.description, + ocs_summary: item.summary, + ocs_version: item.version, + ocs_tags: item.tags, + ocs_changed: item.changed, + ocs_preview_url: item.previewpic1, + ocs_detailpage: item.detailpage, + ocs_created: item.created, + ocs_downloadname: item.downloadname1, + ocs_downloadsize: item.downloadsize1, + ocs_arch: item.download_package_arch1, + ocs_md5sum: item.downloadmd5sum1, + ocs_comments: item.comments, + }; + + all_items.push((catalog_app, extra)); + } + + log::info!("OCS page {}: {} items (total so far: {})", page, page_count, all_items.len()); + + page += 1; + } + + log::info!("OCS catalog fetch complete: {} apps total", all_items.len()); + Ok(all_items) +} + +/// Extract a GitHub repository URL from OCS HTML description. +/// Many OCS app descriptions contain links to their GitHub repo. +fn extract_github_link_from_html(html: &str) -> Option { + // Find "github.com/" in the text (case-insensitive search) + let lower = html.to_lowercase(); + let marker = "github.com/"; + let idx = lower.find(marker)?; + + // Walk backwards from idx to find the URL scheme (https:// or http://) + let before = &html[..idx]; + let scheme_start = before.rfind("https://").or_else(|| before.rfind("http://"))?; + // Make sure the scheme is close to the github.com part (no intervening garbage) + if idx - scheme_start > 20 { + return None; + } + + // From github.com/, extract owner/repo + let after_marker = &html[idx + marker.len()..]; + // Take characters until we hit whitespace, quotes, <, >, or end + let end = after_marker.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '<' || c == '>' || c == ')').unwrap_or(after_marker.len()); + let path = &after_marker[..end]; + + // Split by / to get owner/repo (ignore further path components) + let parts: Vec<&str> = path.splitn(3, '/').collect(); + if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() { + Some(format!("https://github.com/{}/{}", parts[0], parts[1])) + } else { + None + } +} + +/// Map OCS typename to FreeDesktop categories. +fn map_ocs_category(typename: &str) -> Vec { + let s = typename.to_lowercase(); + if s.contains("game") { + vec!["Game".into()] + } else if s.contains("audio") || s.contains("music") { + vec!["Audio".into()] + } else if s.contains("video") || s.contains("multimedia") { + vec!["Video".into()] + } else if s.contains("graphic") || s.contains("photo") { + vec!["Graphics".into()] + } else if s.contains("office") || s.contains("document") { + vec!["Office".into()] + } else if s.contains("development") || s.contains("programming") { + vec!["Development".into()] + } else if s.contains("education") || s.contains("science") { + vec!["Education".into()] + } else if s.contains("network") || s.contains("internet") || s.contains("chat") || s.contains("browser") { + vec!["Network".into()] + } else if s.contains("system") || s.contains("tool") || s.contains("util") { + vec!["System".into()] + } else if typename.is_empty() { + Vec::new() + } else { + vec![typename.to_string()] + } +} + +/// Resolve a fresh download URL for an OCS app at install time. +/// OCS download links are JWT-authenticated and expire, so we fetch a fresh one. +/// `slot` is the 1-based download file slot (default 1 for the primary file). +pub fn resolve_ocs_download(ocs_id: i64, slot: u32) -> Result { + let url = format!( + "https://api.appimagehub.com/ocs/v1/content/download/{}/{}?format=json", + ocs_id, slot + ); + + let response = ureq::get(&url) + .call() + .map_err(|e| CatalogError::Network(format!("OCS download resolve failed: {}", e)))?; + + let body = response.into_body().read_to_string() + .map_err(|e| CatalogError::Network(e.to_string()))?; + + let dl_resp: OcsDownloadResponse = serde_json::from_str(&body) + .map_err(|e| CatalogError::Parse(format!("OCS download JSON parse failed: {}", e)))?; + + dl_resp.data.first() + .and_then(|item| item.download_link.clone()) + .filter(|link| !link.is_empty()) + .ok_or_else(|| CatalogError::Network("No download link in OCS response".into())) +} + +/// Fetch all available download files for an OCS content item. +/// Returns only AppImage files, sorted with newest version first. +pub fn fetch_ocs_download_files(ocs_id: i64) -> Result, CatalogError> { + let url = format!( + "https://api.appimagehub.com/ocs/v1/content/data/{}?format=json", + ocs_id + ); + + let response = ureq::get(&url) + .call() + .map_err(|e| CatalogError::Network(format!("OCS files fetch failed: {}", e)))?; + + let body = response.into_body().read_to_string() + .map_err(|e| CatalogError::Network(e.to_string()))?; + + let json: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| CatalogError::Parse(format!("OCS files JSON parse failed: {}", e)))?; + + let data = json.get("data") + .and_then(|d| d.as_array()) + .ok_or_else(|| CatalogError::Parse("No data array in OCS response".into()))?; + + let item = data.first() + .ok_or_else(|| CatalogError::Parse("Empty data array in OCS response".into()))?; + + let mut files = Vec::new(); + + for slot in 1..=20u32 { + let link_key = format!("downloadlink{}", slot); + let name_key = format!("downloadname{}", slot); + + // Stop if no download link for this slot + let link = match item.get(&link_key).and_then(|v| v.as_str()) { + Some(l) if !l.is_empty() => l, + _ => break, + }; + + let filename = item.get(&name_key) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Skip non-AppImage files + let pkg_type_key = format!("download_package_type{}", slot); + let pkg_type = item.get(&pkg_type_key) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !pkg_type.is_empty() && pkg_type != "appimage" { + continue; + } + let lower_name = filename.to_lowercase(); + if lower_name.ends_with(".dmg") || lower_name.ends_with(".exe") + || lower_name.ends_with(".rpm") || lower_name.ends_with(".deb") + || lower_name.ends_with(".zip") || lower_name.ends_with(".msi") + || lower_name.ends_with(".pkg") || lower_name.ends_with(".7z") + { + continue; + } + + let version_key = format!("download_version{}", slot); + let version = item.get(&version_key) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let size_key = format!("downloadsize{}", slot); + let size_kb = item.get(&size_key) + .and_then(|v| v.as_str().and_then(|s| s.parse().ok()).or_else(|| v.as_i64())); + + let arch_key = format!("download_package_arch{}", slot); + let arch = item.get(&arch_key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + // Ignore the actual download link (JWT, may be expired) - we resolve fresh at install time + let _ = link; + + files.push(OcsDownloadFile { + slot, + ocs_id, + filename, + version, + size_kb, + arch, + pkg_type: if pkg_type.is_empty() { None } else { Some(pkg_type.to_string()) }, + }); + } + + // Sort: newest version first (by slot descending since newer versions are typically added last) + files.reverse(); + + Ok(files) +} + /// Fetch the AppImageHub feed and parse it into CatalogApp entries. fn fetch_appimage_hub() -> Result, CatalogError> { let response = ureq::get(APPIMAGEHUB_API_URL) @@ -273,8 +870,18 @@ fn fetch_custom_catalog(url: &str) -> Result, CatalogError> { }).collect()) } -/// Ensure the default AppImageHub source exists in the database. +/// Ensure the default catalog sources exist in the database. +/// OCS AppImageHub.com is the primary source (richer metadata), and +/// appimage.github.io is the secondary source for apps not in OCS. pub fn ensure_default_sources(db: &Database) { + // Primary: OCS AppImageHub.com (insert first so it syncs first) + db.upsert_catalog_source( + "AppImageHub.com", + OCS_API_URL, + "ocs-appimagehub", + ).ok(); + + // Secondary: appimage.github.io feed db.upsert_catalog_source( "AppImageHub", APPIMAGEHUB_API_URL, @@ -319,6 +926,45 @@ pub fn screenshot_cache_dir() -> PathBuf { dir } +/// Shrink an OCS CDN image URL to a smaller cache size. +/// OCS URLs look like https://images.pling.com/cache/770x540-4/img/... +/// We can replace the size portion to get smaller images. +fn shrink_ocs_image_url(url: &str, size: &str) -> String { + if let Some(start) = url.find("/cache/") { + let after_cache = start + "/cache/".len(); + if let Some(end) = url[after_cache..].find('/') { + let mut result = String::with_capacity(url.len()); + result.push_str(&url[..after_cache]); + result.push_str(size); + result.push_str(&url[after_cache + end..]); + return result; + } + } + url.to_string() +} + +/// Clear the screenshot cache directory to force re-download of screenshots. +/// Called during catalog sync to avoid stale cached images. +fn clear_screenshot_cache() { + let cache_dir = screenshot_cache_dir(); + if cache_dir.exists() { + fs::remove_dir_all(&cache_dir).ok(); + fs::create_dir_all(&cache_dir).ok(); + } +} + +/// Clear icon cache entries for OCS apps to force re-download with updated URLs. +fn clear_ocs_icon_cache(apps: &[CatalogApp]) { + let cache_dir = icon_cache_dir(); + for app in apps { + let sanitized = sanitize_filename(&app.name); + let path = cache_dir.join(format!("{}.png", sanitized)); + if path.exists() { + fs::remove_file(&path).ok(); + } + } +} + /// Resolve an asset path to a full URL (handles relative paths from AppImageHub). fn resolve_asset_url(path: &str) -> String { if path.starts_with("http://") || path.starts_with("https://") { @@ -492,6 +1138,7 @@ mod tests { #[test] fn test_catalog_type_roundtrip() { assert_eq!(CatalogType::from_str("appimage-hub"), CatalogType::AppImageHub); + assert_eq!(CatalogType::from_str("ocs-appimagehub"), CatalogType::OcsAppImageHub); assert_eq!(CatalogType::from_str("github-search"), CatalogType::GitHubSearch); assert_eq!(CatalogType::from_str("custom"), CatalogType::Custom); assert_eq!(CatalogType::from_str("unknown"), CatalogType::Custom); @@ -500,6 +1147,7 @@ mod tests { #[test] fn test_catalog_type_as_str() { assert_eq!(CatalogType::AppImageHub.as_str(), "appimage-hub"); + assert_eq!(CatalogType::OcsAppImageHub.as_str(), "ocs-appimagehub"); assert_eq!(CatalogType::GitHubSearch.as_str(), "github-search"); assert_eq!(CatalogType::Custom.as_str(), "custom"); } @@ -517,9 +1165,12 @@ mod tests { let db = crate::core::database::Database::open_in_memory().unwrap(); ensure_default_sources(&db); let sources = get_sources(&db); - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].name, "AppImageHub"); - assert_eq!(sources[0].source_type, CatalogType::AppImageHub); + assert_eq!(sources.len(), 2); + let ocs = sources.iter().find(|s| s.source_type == CatalogType::OcsAppImageHub); + assert!(ocs.is_some(), "OCS source should exist"); + assert_eq!(ocs.unwrap().name, "AppImageHub.com"); + let hub = sources.iter().find(|s| s.source_type == CatalogType::AppImageHub); + assert!(hub.is_some(), "AppImageHub source should exist"); } #[test] diff --git a/src/core/database.rs b/src/core/database.rs index 1c74fff..459419a 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -88,6 +88,33 @@ pub struct SystemModification { pub previous_value: Option, } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CatalogSortOrder { + NameAsc, + NameDesc, + StarsDesc, + StarsAsc, + DownloadsDesc, + DownloadsAsc, + ReleaseDateDesc, + ReleaseDateAsc, +} + +impl CatalogSortOrder { + pub fn sql_clause(&self) -> &'static str { + match self { + Self::NameAsc => "ORDER BY name COLLATE NOCASE ASC", + Self::NameDesc => "ORDER BY name COLLATE NOCASE DESC", + Self::StarsDesc => "ORDER BY COALESCE(github_stars, 0) DESC, name COLLATE NOCASE ASC", + Self::StarsAsc => "ORDER BY CASE WHEN github_stars IS NULL THEN 1 ELSE 0 END, github_stars ASC, name COLLATE NOCASE ASC", + Self::DownloadsDesc => "ORDER BY (COALESCE(ocs_downloads, 0) + COALESCE(github_downloads, 0)) DESC, name COLLATE NOCASE ASC", + Self::DownloadsAsc => "ORDER BY CASE WHEN ocs_downloads IS NULL AND github_downloads IS NULL THEN 1 ELSE 0 END, (COALESCE(ocs_downloads, 0) + COALESCE(github_downloads, 0)) ASC, name COLLATE NOCASE ASC", + Self::ReleaseDateDesc => "ORDER BY COALESCE(release_date, '0000') DESC, name COLLATE NOCASE ASC", + Self::ReleaseDateAsc => "ORDER BY CASE WHEN release_date IS NULL THEN 1 ELSE 0 END, release_date ASC, name COLLATE NOCASE ASC", + } + } +} + #[derive(Debug, Clone)] pub struct CatalogApp { pub id: i64, @@ -108,6 +135,27 @@ pub struct CatalogApp { pub github_enriched_at: Option, pub github_download_url: Option, pub github_release_assets: Option, + pub github_description: Option, + pub github_readme: Option, + // OCS (appimagehub.com) metadata + pub ocs_id: Option, + pub ocs_downloads: Option, + pub ocs_score: Option, + pub ocs_typename: Option, + pub ocs_personid: Option, + pub ocs_description: Option, + pub ocs_summary: Option, + pub ocs_version: Option, + pub ocs_tags: Option, + pub ocs_changed: Option, + pub ocs_preview_url: Option, + pub ocs_detailpage: Option, + pub ocs_created: Option, + pub ocs_downloadname: Option, + pub ocs_downloadsize: Option, + pub ocs_arch: Option, + pub ocs_md5sum: Option, + pub ocs_comments: Option, } #[derive(Debug, Clone)] @@ -426,6 +474,18 @@ impl Database { self.migrate_to_v15()?; } + if current_version < 16 { + self.migrate_to_v16()?; + } + + if current_version < 17 { + self.migrate_to_v17()?; + } + + if current_version < 18 { + self.migrate_to_v18()?; + } + // Ensure all expected columns exist (repairs DBs where a migration // was updated after it had already run on this database) self.ensure_columns()?; @@ -930,6 +990,68 @@ impl Database { Ok(()) } + fn migrate_to_v16(&self) -> SqlResult<()> { + let new_columns = [ + "github_description TEXT", + "github_readme TEXT", + ]; + for col in &new_columns { + let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col); + self.conn.execute(&sql, []).ok(); + } + self.conn.execute( + "UPDATE schema_version SET version = ?1", + params![16], + )?; + Ok(()) + } + + fn migrate_to_v17(&self) -> SqlResult<()> { + let new_columns = [ + "ocs_id INTEGER", + "ocs_downloads INTEGER", + "ocs_score INTEGER", + "ocs_typename TEXT", + "ocs_personid TEXT", + "ocs_description TEXT", + "ocs_summary TEXT", + "ocs_version TEXT", + "ocs_tags TEXT", + "ocs_changed TEXT", + "ocs_preview_url TEXT", + ]; + for col in &new_columns { + let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col); + self.conn.execute(&sql, []).ok(); + } + self.conn.execute( + "UPDATE schema_version SET version = ?1", + params![17], + )?; + Ok(()) + } + + fn migrate_to_v18(&self) -> SqlResult<()> { + let new_columns = [ + "ocs_detailpage TEXT", + "ocs_created TEXT", + "ocs_downloadname TEXT", + "ocs_downloadsize INTEGER", + "ocs_arch TEXT", + "ocs_md5sum TEXT", + "ocs_comments INTEGER", + ]; + for col in &new_columns { + let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col); + self.conn.execute(&sql, []).ok(); + } + self.conn.execute( + "UPDATE schema_version SET version = ?1", + params![18], + )?; + Ok(()) + } + pub fn upsert_appimage( &self, path: &str, @@ -1095,6 +1217,57 @@ impl Database { previous_version_path, source_url, autostart, startup_wm_class, verification_status, first_run_prompted, system_wide, is_portable, mount_point"; + const CATALOG_APP_COLUMNS: &str = + "id, name, description, categories, download_url, icon_url, homepage, license, screenshots, + github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, + github_enriched_at, github_download_url, github_release_assets, github_description, github_readme, + ocs_id, ocs_downloads, ocs_score, ocs_typename, ocs_personid, ocs_description, ocs_summary, + ocs_version, ocs_tags, ocs_changed, ocs_preview_url, + ocs_detailpage, ocs_created, ocs_downloadname, ocs_downloadsize, ocs_arch, ocs_md5sum, ocs_comments"; + + fn catalog_app_from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(CatalogApp { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + categories: row.get(3)?, + download_url: row.get(4)?, + icon_url: row.get(5)?, + homepage: row.get(6)?, + license: row.get(7)?, + screenshots: row.get(8)?, + github_owner: row.get(9)?, + github_repo: row.get(10)?, + github_stars: row.get(11)?, + github_downloads: row.get(12)?, + latest_version: row.get(13)?, + release_date: row.get(14)?, + github_enriched_at: row.get(15)?, + github_download_url: row.get(16)?, + github_release_assets: row.get(17)?, + github_description: row.get(18)?, + github_readme: row.get(19)?, + ocs_id: row.get(20).unwrap_or(None), + ocs_downloads: row.get(21).unwrap_or(None), + ocs_score: row.get(22).unwrap_or(None), + ocs_typename: row.get(23).unwrap_or(None), + ocs_personid: row.get(24).unwrap_or(None), + ocs_description: row.get(25).unwrap_or(None), + ocs_summary: row.get(26).unwrap_or(None), + ocs_version: row.get(27).unwrap_or(None), + ocs_tags: row.get(28).unwrap_or(None), + ocs_changed: row.get(29).unwrap_or(None), + ocs_preview_url: row.get(30).unwrap_or(None), + ocs_detailpage: row.get(31).unwrap_or(None), + ocs_created: row.get(32).unwrap_or(None), + ocs_downloadname: row.get(33).unwrap_or(None), + ocs_downloadsize: row.get(34).unwrap_or(None), + ocs_arch: row.get(35).unwrap_or(None), + ocs_md5sum: row.get(36).unwrap_or(None), + ocs_comments: row.get(37).unwrap_or(None), + }) + } + fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result { Ok(AppImageRecord { id: row.get(0)?, @@ -2159,16 +2332,16 @@ impl Database { query: &str, category: Option<&str>, limit: i32, + sort: CatalogSortOrder, ) -> SqlResult> { - let mut sql = String::from( - "SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, - github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets - FROM catalog_apps WHERE 1=1" + let mut sql = format!( + "SELECT {} FROM catalog_apps WHERE 1=1", + Self::CATALOG_APP_COLUMNS ); let mut params_list: Vec> = Vec::new(); if !query.is_empty() { - sql.push_str(" AND (name LIKE ?1 OR description LIKE ?1)"); + sql.push_str(" AND (name LIKE ?1 OR description LIKE ?1 OR ocs_summary LIKE ?1 OR ocs_description LIKE ?1)"); params_list.push(Box::new(format!("%{}%", query))); } @@ -2178,34 +2351,13 @@ impl Database { params_list.push(Box::new(format!("%{}%", cat))); } - sql.push_str(&format!(" ORDER BY name LIMIT {}", limit)); + sql.push_str(&format!(" {} LIMIT {}", sort.sql_clause(), limit)); let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_list.iter().map(|p| p.as_ref()).collect(); let mut stmt = self.conn.prepare(&sql)?; - let rows = stmt.query_map(params_refs.as_slice(), |row| { - Ok(CatalogApp { - id: row.get(0)?, - name: row.get(1)?, - description: row.get(2)?, - categories: row.get(3)?, - download_url: row.get(4)?, - icon_url: row.get(5)?, - homepage: row.get(6)?, - license: row.get(7)?, - screenshots: row.get(8)?, - github_owner: row.get(9)?, - github_repo: row.get(10)?, - github_stars: row.get(11)?, - github_downloads: row.get(12)?, - latest_version: row.get(13)?, - release_date: row.get(14)?, - github_enriched_at: row.get(15)?, - github_download_url: row.get(16)?, - github_release_assets: row.get(17)?, - }) - })?; + let rows = stmt.query_map(params_refs.as_slice(), Self::catalog_app_from_row)?; let mut results = Vec::new(); for row in rows { @@ -2215,34 +2367,11 @@ impl Database { } pub fn get_catalog_app(&self, id: i64) -> SqlResult> { - let result = self.conn.query_row( - "SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, - github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets - FROM catalog_apps WHERE id = ?1", - params![id], - |row| { - Ok(CatalogApp { - id: row.get(0)?, - name: row.get(1)?, - description: row.get(2)?, - categories: row.get(3)?, - download_url: row.get(4)?, - icon_url: row.get(5)?, - homepage: row.get(6)?, - license: row.get(7)?, - screenshots: row.get(8)?, - github_owner: row.get(9)?, - github_repo: row.get(10)?, - github_stars: row.get(11)?, - github_downloads: row.get(12)?, - latest_version: row.get(13)?, - release_date: row.get(14)?, - github_enriched_at: row.get(15)?, - github_download_url: row.get(16)?, - github_release_assets: row.get(17)?, - }) - }, + let sql = format!( + "SELECT {} FROM catalog_apps WHERE id = ?1", + Self::CATALOG_APP_COLUMNS ); + let result = self.conn.query_row(&sql, params![id], Self::catalog_app_from_row); match result { Ok(app) => Ok(Some(app)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), @@ -2250,8 +2379,9 @@ impl Database { } } - /// Get featured catalog apps. Apps with GitHub stars sort first (by stars desc), - /// then unenriched apps get a deterministic shuffle that rotates every 15 minutes. + /// Get featured catalog apps. Apps with enrichment data (stars or OCS downloads) + /// sort first by combined popularity, then unenriched apps get a deterministic + /// shuffle that rotates every 15 minutes. pub fn get_featured_catalog_apps(&self, limit: i32) -> SqlResult> { // Time seed rotates every 15 minutes (900 seconds) let time_seed = std::time::SystemTime::now() @@ -2259,46 +2389,31 @@ impl Database { .unwrap_or_default() .as_secs() / 900; - let mut stmt = self.conn.prepare( - "SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, - github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets - FROM catalog_apps + let sql = format!( + "SELECT {} FROM catalog_apps WHERE icon_url IS NOT NULL AND icon_url != '' - AND description IS NOT NULL AND description != '' - AND screenshots IS NOT NULL AND screenshots != ''" - )?; - let rows = stmt.query_map([], |row| { - Ok(CatalogApp { - id: row.get(0)?, - name: row.get(1)?, - description: row.get(2)?, - categories: row.get(3)?, - download_url: row.get(4)?, - icon_url: row.get(5)?, - homepage: row.get(6)?, - license: row.get(7)?, - screenshots: row.get(8)?, - github_owner: row.get(9)?, - github_repo: row.get(10)?, - github_stars: row.get(11)?, - github_downloads: row.get(12)?, - latest_version: row.get(13)?, - release_date: row.get(14)?, - github_enriched_at: row.get(15)?, - github_download_url: row.get(16)?, - github_release_assets: row.get(17)?, - }) - })?; + AND (description IS NOT NULL AND description != '' + OR ocs_summary IS NOT NULL AND ocs_summary != '') + AND (screenshots IS NOT NULL AND screenshots != '' + OR ocs_preview_url IS NOT NULL AND ocs_preview_url != '')", + Self::CATALOG_APP_COLUMNS + ); + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt.query_map([], Self::catalog_app_from_row)?; let mut apps: Vec = rows.collect::>>()?; - // Enriched apps (with stars) sort first by stars descending, - // unenriched apps get the deterministic shuffle after them + // Sort by combined popularity: OCS downloads + GitHub stars. + // Apps with any enrichment sort first, then deterministic shuffle. apps.sort_by(|a, b| { - match (a.github_stars, b.github_stars) { - (Some(sa), Some(sb)) => sb.cmp(&sa), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => { + let a_pop = a.ocs_downloads.unwrap_or(0) + a.github_stars.unwrap_or(0); + let b_pop = b.ocs_downloads.unwrap_or(0) + b.github_stars.unwrap_or(0); + let a_enriched = a_pop > 0; + let b_enriched = b_pop > 0; + match (a_enriched, b_enriched) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + (true, true) => b_pop.cmp(&a_pop), + (false, false) => { let ha = (a.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95); let hb = (b.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95); ha.cmp(&hb) @@ -2369,6 +2484,97 @@ impl Database { Ok(()) } + pub fn insert_ocs_catalog_app( + &self, + source_id: i64, + name: &str, + ocs_id: i64, + description: Option<&str>, + summary: Option<&str>, + categories: Option<&str>, + version: Option<&str>, + download_url: &str, + icon_url: Option<&str>, + homepage: Option<&str>, + screenshots: Option<&str>, + ocs_downloads: Option, + ocs_score: Option, + ocs_typename: Option<&str>, + ocs_personid: Option<&str>, + ocs_tags: Option<&str>, + ocs_changed: Option<&str>, + ocs_preview_url: Option<&str>, + license: Option<&str>, + ocs_detailpage: Option<&str>, + ocs_created: Option<&str>, + ocs_downloadname: Option<&str>, + ocs_downloadsize: Option, + ocs_arch: Option<&str>, + ocs_md5sum: Option<&str>, + ocs_comments: Option, + ) -> SqlResult<()> { + self.conn.execute( + "INSERT INTO catalog_apps + (source_id, name, description, categories, latest_version, download_url, icon_url, homepage, + screenshots, license, cached_at, + ocs_id, ocs_downloads, ocs_score, ocs_typename, ocs_personid, ocs_description, ocs_summary, + ocs_version, ocs_tags, ocs_changed, ocs_preview_url, + ocs_detailpage, ocs_created, ocs_downloadname, ocs_downloadsize, ocs_arch, ocs_md5sum, ocs_comments) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'), + ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, + ?22, ?23, ?24, ?25, ?26, ?27, ?28) + ON CONFLICT(source_id, name) DO UPDATE SET + description = excluded.description, + categories = excluded.categories, + latest_version = excluded.latest_version, + download_url = excluded.download_url, + icon_url = excluded.icon_url, + homepage = excluded.homepage, + screenshots = excluded.screenshots, + license = excluded.license, + cached_at = datetime('now'), + ocs_id = excluded.ocs_id, + ocs_downloads = excluded.ocs_downloads, + ocs_score = excluded.ocs_score, + ocs_typename = excluded.ocs_typename, + ocs_personid = excluded.ocs_personid, + ocs_description = excluded.ocs_description, + ocs_summary = excluded.ocs_summary, + ocs_version = excluded.ocs_version, + ocs_tags = excluded.ocs_tags, + ocs_changed = excluded.ocs_changed, + ocs_preview_url = excluded.ocs_preview_url, + ocs_detailpage = excluded.ocs_detailpage, + ocs_created = excluded.ocs_created, + ocs_downloadname = excluded.ocs_downloadname, + ocs_downloadsize = excluded.ocs_downloadsize, + ocs_arch = excluded.ocs_arch, + ocs_md5sum = excluded.ocs_md5sum, + ocs_comments = excluded.ocs_comments", + params![ + source_id, name, summary, categories, version, download_url, icon_url, homepage, + screenshots, license, + ocs_id, ocs_downloads, ocs_score, ocs_typename, ocs_personid, description, summary, + version, ocs_tags, ocs_changed, ocs_preview_url, + ocs_detailpage, ocs_created, ocs_downloadname, ocs_downloadsize, ocs_arch, ocs_md5sum, ocs_comments + ], + )?; + Ok(()) + } + + /// Get all app names for a given source (used for deduplication). + pub fn get_catalog_app_names_for_source(&self, source_id: i64) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT LOWER(name) FROM catalog_apps WHERE source_id = ?1" + )?; + let rows = stmt.query_map(params![source_id], |row| row.get::<_, String>(0))?; + let mut names = std::collections::HashSet::new(); + for row in rows { + names.insert(row?); + } + Ok(names) + } + pub fn search_catalog_apps(&self, query: &str) -> SqlResult> { let pattern = format!("%{}%", query); let mut stmt = self.conn.prepare( @@ -2453,10 +2659,11 @@ impl Database { app_id: i64, stars: i64, pushed_at: Option<&str>, + description: Option<&str>, ) -> SqlResult<()> { self.conn.execute( - "UPDATE catalog_apps SET github_stars = ?2, github_enriched_at = datetime('now') WHERE id = ?1", - params![app_id, stars], + "UPDATE catalog_apps SET github_stars = ?2, github_description = COALESCE(?3, github_description), github_enriched_at = datetime('now') WHERE id = ?1", + params![app_id, stars, description], )?; // Store pushed_at in release_date if no release info yet if let Some(pushed) = pushed_at { @@ -2492,36 +2699,15 @@ impl Database { } pub fn get_unenriched_catalog_apps(&self, limit: i32) -> SqlResult> { - let mut stmt = self.conn.prepare( - "SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, - github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets - FROM catalog_apps + let sql = format!( + "SELECT {} FROM catalog_apps WHERE github_owner IS NOT NULL AND github_enriched_at IS NULL ORDER BY id - LIMIT ?1" - )?; - let rows = stmt.query_map(params![limit], |row| { - Ok(CatalogApp { - id: row.get(0)?, - name: row.get(1)?, - description: row.get(2)?, - categories: row.get(3)?, - download_url: row.get(4)?, - icon_url: row.get(5)?, - homepage: row.get(6)?, - license: row.get(7)?, - screenshots: row.get(8)?, - github_owner: row.get(9)?, - github_repo: row.get(10)?, - github_stars: row.get(11)?, - github_downloads: row.get(12)?, - latest_version: row.get(13)?, - release_date: row.get(14)?, - github_enriched_at: row.get(15)?, - github_download_url: row.get(16)?, - github_release_assets: row.get(17)?, - }) - })?; + LIMIT ?1", + Self::CATALOG_APP_COLUMNS + ); + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt.query_map(params![limit], Self::catalog_app_from_row)?; rows.collect() } @@ -2538,6 +2724,18 @@ impl Database { )?; Ok((enriched, total_with_github)) } + + pub fn update_catalog_app_readme( + &self, + app_id: i64, + readme: &str, + ) -> SqlResult<()> { + self.conn.execute( + "UPDATE catalog_apps SET github_readme = ?2 WHERE id = ?1", + params![app_id, readme], + )?; + Ok(()) + } } #[cfg(test)] @@ -2708,7 +2906,7 @@ mod tests { [], |row| row.get(0), ).unwrap(); - assert_eq!(version, 15); + assert_eq!(version, 18); // All tables that should exist after the full v1-v7 migration chain let expected_tables = [ diff --git a/src/core/github_enrichment.rs b/src/core/github_enrichment.rs index 9419de2..16270ee 100644 --- a/src/core/github_enrichment.rs +++ b/src/core/github_enrichment.rs @@ -110,6 +110,54 @@ pub fn fetch_release_info(owner: &str, repo: &str, token: &str) -> Result<(GitHu Ok((info, remaining)) } +#[derive(Debug, serde::Deserialize)] +struct GitHubReadmeResponse { + content: String, + #[serde(default)] + encoding: String, +} + +/// Fetch the README content for a repo (decoded from base64). +pub fn fetch_readme(owner: &str, repo: &str, token: &str) -> Result<(String, u32), String> { + let url = format!("https://api.github.com/repos/{}/{}/readme", owner, repo); + let (body, remaining) = github_get(&url, token)?; + let resp: GitHubReadmeResponse = serde_json::from_str(&body) + .map_err(|e| format!("Parse error: {}", e))?; + + if resp.encoding != "base64" { + return Err(format!("Unexpected encoding: {}", resp.encoding)); + } + + // GitHub returns base64 with newlines; strip them before decoding + let clean = resp.content.replace('\n', ""); + let decoded = base64_decode(&clean) + .map_err(|e| format!("Base64 decode error: {}", e))?; + let text = String::from_utf8(decoded) + .map_err(|e| format!("UTF-8 error: {}", e))?; + Ok((text, remaining)) +} + +/// Simple base64 decoder (standard alphabet, no padding required). +fn base64_decode(input: &str) -> Result, String> { + const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut output = Vec::with_capacity(input.len() * 3 / 4); + let mut buf = 0u32; + let mut bits = 0u32; + for &b in input.as_bytes() { + if b == b'=' { break; } + let val = TABLE.iter().position(|&c| c == b) + .ok_or_else(|| format!("Invalid base64 char: {}", b as char))? as u32; + buf = (buf << 6) | val; + bits += 6; + if bits >= 8 { + bits -= 8; + output.push((buf >> bits) as u8); + buf &= (1 << bits) - 1; + } + } + Ok(output) +} + // --- AppImage asset filtering --- /// A simplified release asset for storage (JSON-serializable). @@ -163,7 +211,7 @@ pub fn pick_best_asset(assets: &[AppImageAsset]) -> Option<&AppImageAsset> { // --- Enrichment logic --- -/// Enrich a catalog app with repo-level info (stars, pushed_at). +/// Enrich a catalog app with repo-level info (stars, pushed_at, description). pub fn enrich_app_repo_info( db: &Database, app_id: i64, @@ -172,8 +220,9 @@ pub fn enrich_app_repo_info( token: &str, ) -> Result { let (info, remaining) = fetch_repo_info(owner, repo, token)?; - db.update_catalog_app_github_metadata(app_id, info.stargazers_count, info.pushed_at.as_deref()) - .map_err(|e| format!("DB error: {}", e))?; + db.update_catalog_app_github_metadata( + app_id, info.stargazers_count, info.pushed_at.as_deref(), info.description.as_deref(), + ).map_err(|e| format!("DB error: {}", e))?; Ok(remaining) } @@ -216,6 +265,20 @@ pub fn enrich_app_release_info( Ok(remaining) } +/// Fetch and store the README for a catalog app. +pub fn enrich_app_readme( + db: &Database, + app_id: i64, + owner: &str, + repo: &str, + token: &str, +) -> Result { + let (readme, remaining) = fetch_readme(owner, repo, token)?; + db.update_catalog_app_readme(app_id, &readme) + .map_err(|e| format!("DB error: {}", e))?; + Ok(remaining) +} + /// Background enrichment: process a batch of unenriched apps. /// Returns (count_enriched, should_continue). pub fn background_enrich_batch( @@ -261,7 +324,7 @@ pub fn background_enrich_batch( Err(e) => { log::warn!("Failed to enrich {}/{}: {}", owner, repo, e); // Mark as enriched anyway so we don't retry forever - db.update_catalog_app_github_metadata(app.id, 0, None).ok(); + db.update_catalog_app_github_metadata(app.id, 0, None, None).ok(); } } diff --git a/src/ui/app_card.rs b/src/ui/app_card.rs index 99152e4..1bce9cb 100644 --- a/src/ui/app_card.rs +++ b/src/ui/app_card.rs @@ -101,6 +101,7 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild { .child(&card) .build(); child.add_css_class("activatable"); + super::widgets::set_pointer_cursor(&child); // Accessible label for screen readers let accessible_name = build_accessible_label(record); diff --git a/src/ui/catalog_detail.rs b/src/ui/catalog_detail.rs index 6219d13..44d0465 100644 --- a/src/ui/catalog_detail.rs +++ b/src/ui/catalog_detail.rs @@ -72,11 +72,14 @@ pub fn build_catalog_detail_page( .build(); info_box.append(&name_label); - // Author (from homepage URL domain) - if let Some(ref homepage) = app.homepage { - let author_text = extract_author(homepage); + // Author (prefer OCS personid, then homepage URL domain) + let author_text = app.ocs_personid.as_deref() + .filter(|p| !p.is_empty()) + .map(|p| p.to_string()) + .or_else(|| app.homepage.as_deref().map(|h| extract_author(h))); + if let Some(author) = author_text { let author_label = gtk::Label::builder() - .label(&format!("by {}", author_text)) + .label(&format!("by {}", author)) .css_classes(["dim-label"]) .xalign(0.0) .halign(gtk::Align::Start) @@ -108,6 +111,14 @@ pub fn build_catalog_detail_page( let install_slot = gtk::Box::new(gtk::Orientation::Horizontal, 0); + // Shared OCS download files, populated by async fetch + let ocs_files_shared: Rc>> = + Rc::new(RefCell::new(Vec::new())); + + // Whether we need to load OCS download files + let has_ocs = app.ocs_id.is_some(); + let awaiting_ocs = has_ocs && !is_installed; + if is_installed { let installed_badge = widgets::status_badge(&i18n("Installed"), "success"); installed_badge.set_valign(gtk::Align::Center); @@ -115,8 +126,8 @@ pub fn build_catalog_detail_page( } else { button_box.append(&install_slot); - if awaiting_github { - // GitHub release data not yet loaded - show disabled placeholder + if awaiting_github || awaiting_ocs { + // Data not yet loaded - show disabled placeholder let placeholder = gtk::Button::builder() .label(&i18n("Loading...")) .css_classes(["suggested-action", "pill"]) @@ -133,6 +144,8 @@ pub fn build_catalog_detail_page( toast_overlay, db, app.homepage.as_deref(), + app.ocs_id, + &[], ); } } @@ -156,21 +169,30 @@ pub fn build_catalog_detail_page( header_box.append(&info_box); content.append(&header_box); - // --- GitHub stat cards row (between header and screenshots) --- + // --- Stat cards row (between header and screenshots) --- + // Prefer OCS data where available, fall back to GitHub data + let display_downloads = app.ocs_downloads.filter(|&d| d > 0) + .or(app.github_downloads.filter(|&d| d > 0)); + let display_version = app.ocs_version.as_deref() + .filter(|v| !v.is_empty()) + .or(app.latest_version.as_deref()); + let has_ocs = app.ocs_id.is_some(); + let has_stats = has_github || has_ocs; + let stars_value_label = gtk::Label::builder() .label(app.github_stars.filter(|&s| s > 0).map(|s| format_count(s)).as_deref().unwrap_or("-")) .css_classes(["stat-value"]) .xalign(0.0) .build(); let version_value_label = gtk::Label::builder() - .label(app.latest_version.as_deref().unwrap_or("-")) + .label(display_version.unwrap_or("-")) .css_classes(["stat-value"]) .xalign(0.0) .ellipsize(gtk::pango::EllipsizeMode::End) .max_width_chars(14) .build(); let downloads_value_label = gtk::Label::builder() - .label(app.github_downloads.filter(|&d| d > 0).map(|d| format_count(d)).as_deref().unwrap_or("-")) + .label(display_downloads.map(|d| format_count(d)).as_deref().unwrap_or("-")) .css_classes(["stat-value"]) .xalign(0.0) .build(); @@ -180,16 +202,28 @@ pub fn build_catalog_detail_page( .xalign(0.0) .build(); - if has_github { + if has_stats { let stats_row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(10) .homogeneous(true) .build(); - let stars_card = build_stat_card("starred-symbolic", &stars_value_label, &i18n("Stars")); - stars_card.add_css_class("stat-stars"); - stats_row.append(&stars_card); + // Show stars card if we have GitHub data, or score if we have OCS data + if has_github { + let stars_card = build_stat_card("starred-symbolic", &stars_value_label, &i18n("Stars")); + stars_card.add_css_class("stat-stars"); + stats_row.append(&stars_card); + } else if let Some(score) = app.ocs_score.filter(|&s| s > 0) { + let score_label = gtk::Label::builder() + .label(&format!("{}%", score)) + .css_classes(["stat-value"]) + .xalign(0.0) + .build(); + let score_card = build_stat_card("starred-symbolic", &score_label, &i18n("Score")); + score_card.add_css_class("stat-stars"); + stats_row.append(&score_card); + } let version_card = build_stat_card("tag-symbolic", &version_value_label, &i18n("Latest")); version_card.add_css_class("stat-version"); @@ -199,9 +233,22 @@ pub fn build_catalog_detail_page( downloads_card.add_css_class("stat-downloads"); stats_row.append(&downloads_card); - let released_card = build_stat_card("month-symbolic", &released_value_label, &i18n("Released")); - released_card.add_css_class("stat-released"); - stats_row.append(&released_card); + if has_github { + let released_card = build_stat_card("month-symbolic", &released_value_label, &i18n("Released")); + released_card.add_css_class("stat-released"); + stats_row.append(&released_card); + } else if let Some(ref changed) = app.ocs_changed { + if !changed.is_empty() { + let changed_label = gtk::Label::builder() + .label(&widgets::relative_time(changed)) + .css_classes(["stat-value"]) + .xalign(0.0) + .build(); + let changed_card = build_stat_card("month-symbolic", &changed_label, &i18n("Updated")); + changed_card.add_css_class("stat-released"); + stats_row.append(&changed_card); + } + } content.append(&stats_row); } @@ -214,10 +261,63 @@ pub fn build_catalog_detail_page( .width_request(16) .height_request(16) .build(); - if has_github { + if has_github && needs_enrichment { content.append(&enrich_spinner); } + // --- About section container (created early so enrichment callback can update it) --- + let about_container = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(0) + .build(); + { + // Prefer long description for detail page (ocs_description is full HTML with + // features, changelog etc.; ocs_summary and description are short one-liners) + let display_desc = app.ocs_description.as_deref() + .filter(|d| !d.is_empty()) + .or(app.github_description.as_deref().filter(|d| !d.is_empty())) + .or(app.description.as_deref().filter(|d| !d.is_empty())); + if let Some(desc) = display_desc { + let about_group = adw::PreferencesGroup::builder() + .title(&i18n("About")) + .build(); + let plain_desc = catalog_tile::html_to_description(desc); + let desc_label = gtk::Label::builder() + .label(&plain_desc) + .wrap(true) + .xalign(0.0) + .selectable(true) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .build(); + let row = adw::ActionRow::new(); + row.set_child(Some(&desc_label)); + about_group.add(&row); + about_container.append(&about_group); + } + } + + // --- README section container (created early so enrichment callback can update it) --- + let readme_container = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build(); + if let Some(ref readme) = app.github_readme { + if !readme.is_empty() { + let readme_label = gtk::Label::builder() + .label(&i18n("README")) + .css_classes(["title-2"]) + .xalign(0.0) + .halign(gtk::Align::Start) + .build(); + readme_container.append(&readme_label); + let rendered = widgets::build_markdown_view(readme); + readme_container.append(&rendered); + } + } + // On-demand enrichment: fetch release info if stale or missing if has_github && needs_enrichment { let app_id = app.id; @@ -231,6 +331,9 @@ pub fn build_catalog_detail_page( let spinner_ref = enrich_spinner.clone(); let install_slot_ref = install_slot.clone(); let toast_ref = toast_overlay.clone(); + let about_ref = about_container.clone(); + let readme_ref = readme_container.clone(); + let ocs_files_ref = ocs_files_shared.clone(); spinner_ref.set_visible(true); spinner_ref.set_spinning(true); @@ -252,6 +355,9 @@ pub fn build_catalog_detail_page( let _ = github_enrichment::enrich_app_repo_info( db, app_id, &owner_c, &repo_c, &token_c, ); + let _ = github_enrichment::enrich_app_readme( + db, app_id, &owner_c, &repo_c, &token_c, + ); } }).await; @@ -274,6 +380,7 @@ pub fn build_catalog_detail_page( // Rebuild install button now that GitHub data is available if awaiting_github && !is_installed { + let files = ocs_files_ref.borrow(); populate_install_slot( &install_slot_ref, &updated.name, @@ -283,8 +390,48 @@ pub fn build_catalog_detail_page( &toast_ref, &db_ref, updated.homepage.as_deref(), + updated.ocs_id, + &files, ); } + + // Update about description if GitHub description is now available + if let Some(ref desc) = updated.github_description { + if !desc.is_empty() && about_ref.first_child().is_none() { + let about_group = adw::PreferencesGroup::builder() + .title(&i18n("About")) + .build(); + let desc_label = gtk::Label::builder() + .label(desc) + .wrap(true) + .xalign(0.0) + .selectable(true) + .margin_top(8) + .margin_bottom(8) + .margin_start(12) + .margin_end(12) + .build(); + let row = adw::ActionRow::new(); + row.set_child(Some(&desc_label)); + about_group.add(&row); + about_ref.append(&about_group); + } + } + + // Populate README if now available + if let Some(ref readme) = updated.github_readme { + if !readme.is_empty() && readme_ref.first_child().is_none() { + let readme_heading = gtk::Label::builder() + .label(&i18n("README")) + .css_classes(["title-2"]) + .xalign(0.0) + .halign(gtk::Align::Start) + .build(); + readme_ref.append(&readme_heading); + let rendered = widgets::build_markdown_view(readme); + readme_ref.append(&rendered); + } + } } if result.is_err() { @@ -293,9 +440,72 @@ pub fn build_catalog_detail_page( }); } - // --- Screenshots section (click to open lightbox) --- - if let Some(ref screenshots_str) = app.screenshots { - let paths: Vec<&str> = screenshots_str.split(';').filter(|s| !s.is_empty()).collect(); + // On-demand OCS download files fetch: get all available versions for the install dropdown + if awaiting_ocs { + let ocs_id_val = app.ocs_id.unwrap(); + let install_slot_ocs = install_slot.clone(); + let toast_ocs = toast_overlay.clone(); + let db_ocs = db.clone(); + let app_name_ocs = app.name.clone(); + let dl_url_ocs = app.download_url.clone(); + let gh_dl_url = app.github_download_url.clone(); + let gh_assets = app.github_release_assets.clone(); + let hp_ocs = app.homepage.clone(); + let ocs_id_opt = app.ocs_id; + let ocs_files_ocs = ocs_files_shared.clone(); + + glib::spawn_future_local(async move { + let id = ocs_id_val; + let result = gio::spawn_blocking(move || { + catalog::fetch_ocs_download_files(id) + }).await; + + match result { + Ok(Ok(files)) => { + *ocs_files_ocs.borrow_mut() = files.clone(); + populate_install_slot( + &install_slot_ocs, + &app_name_ocs, + &dl_url_ocs, + gh_dl_url.as_deref(), + gh_assets.as_deref(), + &toast_ocs, + &db_ocs, + hp_ocs.as_deref(), + ocs_id_opt, + &files, + ); + } + _ => { + // Fetch failed - populate with basic button (no OCS file data) + populate_install_slot( + &install_slot_ocs, + &app_name_ocs, + &dl_url_ocs, + gh_dl_url.as_deref(), + gh_assets.as_deref(), + &toast_ocs, + &db_ocs, + hp_ocs.as_deref(), + ocs_id_opt, + &[], + ); + } + } + }); + } + + // --- Screenshots section (paged carousel with arrows, click for lightbox) --- + // Use screenshots field (populated from either OCS preview pics or AppImageHub) + // Fall back to ocs_preview_url if screenshots is empty + let screenshots_source = app.screenshots.as_deref() + .filter(|s| !s.is_empty()) + .or(app.ocs_preview_url.as_deref().filter(|s| !s.is_empty())); + if let Some(screenshots_str) = screenshots_source { + let paths: Vec = screenshots_str.split(';') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); if !paths.is_empty() { let screenshots_label = gtk::Label::builder() .label(&i18n("Screenshots")) @@ -306,141 +516,240 @@ pub fn build_catalog_detail_page( .build(); content.append(&screenshots_label); - let screenshot_scroll = gtk::ScrolledWindow::builder() - .vscrollbar_policy(gtk::PolicyType::Never) - .hscrollbar_policy(gtk::PolicyType::Automatic) - .height_request(360) - .build(); + const SCREENSHOTS_PER_PAGE: usize = 2; + let total_screenshots = paths.len(); + let max_page = total_screenshots.saturating_sub(1) / SCREENSHOTS_PER_PAGE; - let screenshot_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(12) - .build(); - - // Store textures for lightbox access + // Store all textures for lightbox (across all pages) let textures: Rc>>> = - Rc::new(RefCell::new(vec![None; paths.len()])); + Rc::new(RefCell::new(vec![None; total_screenshots])); - for (i, path) in paths.iter().enumerate() { - let frame = gtk::Frame::new(None); - frame.add_css_class("card"); - frame.set_width_request(480); - frame.set_height_request(340); + // Store all frame refs so we can show/hide pages + let all_paths: Rc> = Rc::new(paths); + let screenshot_page: Rc> = Rc::new(std::cell::Cell::new(0)); - // Spinner placeholder - let spinner = gtk::Spinner::builder() - .halign(gtk::Align::Center) - .valign(gtk::Align::Center) - .spinning(true) - .width_request(48) - .height_request(48) - .build(); - frame.set_child(Some(&spinner)); + // Stack for crossfade page transitions + let ss_stack = gtk::Stack::builder() + .transition_type(gtk::StackTransitionType::Crossfade) + .transition_duration(200) + .hexpand(true) + .height_request(340) + .build(); - screenshot_box.append(&frame); + // Navigation arrows + let ss_left = gtk::Button::builder() + .icon_name("go-previous-symbolic") + .css_classes(["circular", "osd"]) + .valign(gtk::Align::Center) + .sensitive(false) + .build(); - // Click handler for lightbox - let textures_click = textures.clone(); - let click = gtk::GestureClick::new(); - let idx = i; - click.connect_released(move |gesture, _, _, _| { - gesture.set_state(gtk::EventSequenceState::Claimed); - let t = textures_click.borrow(); - if t.get(idx).is_some_and(|t| t.is_some()) { - if let Some(widget) = gesture.widget() { - if let Some(root) = gtk::prelude::WidgetExt::root(&widget) { - if let Ok(window) = root.downcast::() { - detail_view::show_screenshot_lightbox( - &window, - &textures_click, - idx, - ); + let ss_right = gtk::Button::builder() + .icon_name("go-next-symbolic") + .css_classes(["circular", "osd"]) + .valign(gtk::Align::Center) + .sensitive(max_page > 0) + .build(); + + // Carousel row: [<] [stack] [>] + let ss_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + ss_row.append(&ss_left); + ss_row.append(&ss_stack); + ss_row.append(&ss_right); + + // Flip state for crossfade (alternates between "a" and "b" stack children) + let ss_flip: Rc> = Rc::new(std::cell::Cell::new(false)); + + // Build and show a page of screenshots + let build_ss_page = { + let textures_ref = textures.clone(); + let paths_ref = all_paths.clone(); + let app_name = app.name.clone(); + Rc::new(move |page: usize, stack: >k::Stack, flip: &Rc>, + left: >k::Button, right: >k::Button| { + let start = page * SCREENSHOTS_PER_PAGE; + let end = (start + SCREENSHOTS_PER_PAGE).min(paths_ref.len()); + + left.set_sensitive(page > 0); + right.set_sensitive(page < max_page); + + let page_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .homogeneous(true) + .hexpand(true) + .build(); + + for i in start..end { + let frame = gtk::Frame::new(None); + frame.add_css_class("card"); + frame.set_height_request(320); + + let spinner = gtk::Spinner::builder() + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .spinning(true) + .width_request(32) + .height_request(32) + .build(); + frame.set_child(Some(&spinner)); + + widgets::set_pointer_cursor(&frame); + + // Click handler for lightbox + let textures_click = textures_ref.clone(); + let click = gtk::GestureClick::new(); + let idx = i; + click.connect_released(move |gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + let t = textures_click.borrow(); + if t.get(idx).is_some_and(|t| t.is_some()) { + if let Some(widget) = gesture.widget() { + if let Some(root) = gtk::prelude::WidgetExt::root(&widget) { + if let Ok(window) = root.downcast::() { + detail_view::show_screenshot_lightbox( + &window, + &textures_click, + idx, + ); + } + } } } - } + }); + frame.add_controller(click); + + // Load screenshot asynchronously + let name = app_name.clone(); + let spath = paths_ref[i].clone(); + let frame_ref = frame.clone(); + let tex_ref = textures_ref.clone(); + + glib::spawn_future_local(async move { + let n = name.clone(); + let sp = spath.clone(); + let load_idx = i; + + let result = gio::spawn_blocking(move || { + catalog::cache_screenshot(&n, &sp, load_idx) + .map_err(|e| e.to_string()) + }).await; + + match result { + Ok(Ok(local_path)) => { + if let Ok(texture) = gtk::gdk::Texture::from_filename(&local_path) { + let picture = gtk::Picture::builder() + .paintable(&texture) + .content_fit(gtk::ContentFit::Contain) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .build(); + frame_ref.set_child(Some(&picture)); + tex_ref.borrow_mut()[load_idx] = Some(texture); + } + } + _ => { + let fallback = gtk::Label::builder() + .label("Screenshot unavailable") + .css_classes(["dim-label"]) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .build(); + frame_ref.set_child(Some(&fallback)); + } + } + }); + + page_box.append(&frame); + } + + // Crossfade: alternate between "a" and "b" children + let current_flip = flip.get(); + let child_name = if current_flip { "ss_b" } else { "ss_a" }; + stack.add_named(&page_box, Some(child_name)); + stack.set_visible_child_name(child_name); + // Remove the old child + let old_name = if current_flip { "ss_a" } else { "ss_b" }; + if let Some(old_child) = stack.child_by_name(old_name) { + stack.remove(&old_child); + } + flip.set(!current_flip); + }) + }; + + // Show initial page + build_ss_page(0, &ss_stack, &ss_flip, &ss_left, &ss_right); + + // Wire arrow clicks + { + let page_ref = screenshot_page.clone(); + let stack_ref = ss_stack.clone(); + let flip_ref = ss_flip.clone(); + let left_ref = ss_left.clone(); + let right_ref = ss_right.clone(); + let build_ref = build_ss_page.clone(); + ss_left.connect_clicked(move |_| { + let page = page_ref.get(); + if page > 0 { + page_ref.set(page - 1); + build_ref(page - 1, &stack_ref, &flip_ref, &left_ref, &right_ref); } }); - frame.add_controller(click); - - // Load screenshot asynchronously - let app_name = app.name.clone(); - let screenshot_path = path.to_string(); - let frame_ref = frame.clone(); - let textures_ref = textures.clone(); - - glib::spawn_future_local(async move { - let name = app_name.clone(); - let spath = screenshot_path.clone(); - let load_idx = i; - - let result = gio::spawn_blocking(move || { - catalog::cache_screenshot(&name, &spath, load_idx) - .map_err(|e| e.to_string()) - }).await; - - match result { - Ok(Ok(local_path)) => { - if let Ok(texture) = gtk::gdk::Texture::from_filename(&local_path) { - let picture = gtk::Picture::builder() - .paintable(&texture) - .content_fit(gtk::ContentFit::Contain) - .halign(gtk::Align::Center) - .valign(gtk::Align::Center) - .build(); - frame_ref.set_child(Some(&picture)); - textures_ref.borrow_mut()[i] = Some(texture); - } - } - _ => { - let fallback = gtk::Label::builder() - .label("Screenshot unavailable") - .css_classes(["dim-label"]) - .halign(gtk::Align::Center) - .valign(gtk::Align::Center) - .build(); - frame_ref.set_child(Some(&fallback)); - } + } + { + let page_ref = screenshot_page.clone(); + let stack_ref = ss_stack.clone(); + let flip_ref = ss_flip.clone(); + let left_ref = ss_left.clone(); + let right_ref = ss_right.clone(); + let build_ref = build_ss_page.clone(); + ss_right.connect_clicked(move |_| { + let page = page_ref.get(); + if page < max_page { + page_ref.set(page + 1); + build_ref(page + 1, &stack_ref, &flip_ref, &left_ref, &right_ref); } }); } - screenshot_scroll.set_child(Some(&screenshot_box)); - content.append(&screenshot_scroll); + content.append(&ss_row); } } - // --- About section --- - if let Some(ref desc) = app.description { - if !desc.is_empty() { - let about_group = adw::PreferencesGroup::builder() - .title(&i18n("About")) - .build(); - - let plain_desc = catalog_tile::strip_html(desc); - let desc_label = gtk::Label::builder() - .label(&plain_desc) - .wrap(true) - .xalign(0.0) - .selectable(true) - .margin_top(8) - .margin_bottom(8) - .margin_start(12) - .margin_end(12) - .build(); - - let row = adw::ActionRow::new(); - row.set_child(Some(&desc_label)); - about_group.add(&row); - - content.append(&about_group); - } - } + content.append(&about_container); + content.append(&readme_container); // --- Details section --- let details_group = adw::PreferencesGroup::builder() .title(&i18n("Details")) .build(); + // Type/Category from OCS + if let Some(ref typename) = app.ocs_typename { + if !typename.is_empty() { + let escaped = typename.replace('&', "&"); + let row = adw::ActionRow::builder() + .title(&i18n("Type")) + .subtitle(&escaped) + .build(); + details_group.add(&row); + } + } + + if let Some(ref cats) = app.categories { + if !cats.is_empty() { + let escaped_cats = cats.replace('&', "&"); + let row = adw::ActionRow::builder() + .title(&i18n("Categories")) + .subtitle(&escaped_cats) + .build(); + details_group.add(&row); + } + } + if let Some(ref license) = app.license { if !license.is_empty() { let row = adw::ActionRow::builder() @@ -452,56 +761,175 @@ pub fn build_catalog_detail_page( } } - if let Some(ref cats) = app.categories { - if !cats.is_empty() { + // Architecture + if let Some(ref arch) = app.ocs_arch { + if !arch.is_empty() { let row = adw::ActionRow::builder() - .title(&i18n("Categories")) - .subtitle(cats) + .title(&i18n("Architecture")) + .subtitle(arch) .build(); details_group.add(&row); } } - if let Some(ref homepage) = app.homepage { - let row = adw::ActionRow::builder() - .title(&i18n("Homepage")) - .subtitle(homepage) - .subtitle_selectable(true) - .activatable(true) - .build(); - let arrow = gtk::Image::from_icon_name("external-link-symbolic"); - arrow.set_valign(gtk::Align::Center); - row.add_suffix(&arrow); - - let hp = homepage.clone(); - row.connect_activated(move |row| { - let launcher = gtk::UriLauncher::new(&hp); - let root = row.root().and_downcast::(); - launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); - }); - details_group.add(&row); + // File name + if let Some(ref dlname) = app.ocs_downloadname { + if !dlname.is_empty() { + let row = adw::ActionRow::builder() + .title(&i18n("Filename")) + .subtitle(dlname) + .subtitle_selectable(true) + .build(); + details_group.add(&row); + } } - // Download URL - let dl_row = adw::ActionRow::builder() - .title(&i18n("Download")) - .subtitle(&app.download_url) - .subtitle_selectable(true) - .activatable(true) - .build(); - let dl_arrow = gtk::Image::from_icon_name("external-link-symbolic"); - dl_arrow.set_valign(gtk::Align::Center); - dl_row.add_suffix(&dl_arrow); - let dl_url = app.download_url.clone(); - dl_row.connect_activated(move |row| { - let launcher = gtk::UriLauncher::new(&dl_url); - let root = row.root().and_downcast::(); - launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); - }); - details_group.add(&dl_row); + // File size + if let Some(size_kb) = app.ocs_downloadsize { + if size_kb > 0 { + let row = adw::ActionRow::builder() + .title(&i18n("File size")) + .subtitle(&widgets::format_size(size_kb * 1024)) + .build(); + details_group.add(&row); + } + } + + // MD5 checksum + if let Some(ref md5) = app.ocs_md5sum { + if !md5.is_empty() { + let row = adw::ActionRow::builder() + .title(&i18n("MD5")) + .subtitle(md5) + .subtitle_selectable(true) + .build(); + details_group.add(&row); + } + } + + if let Some(ref tags) = app.ocs_tags { + if !tags.is_empty() { + // Format tags nicely with commas + let formatted = tags.split(',') + .map(|t| t.trim()) + .filter(|t| !t.is_empty()) + .collect::>() + .join(", ") + .replace('&', "&"); + let row = adw::ActionRow::builder() + .title(&i18n("Tags")) + .subtitle(&formatted) + .subtitle_selectable(true) + .build(); + details_group.add(&row); + } + } + + // Created date + if let Some(ref created) = app.ocs_created { + if !created.is_empty() { + let row = adw::ActionRow::builder() + .title(&i18n("Published")) + .subtitle(&widgets::relative_time(created)) + .build(); + details_group.add(&row); + } + } + + // Comments count + if let Some(comments) = app.ocs_comments { + if comments > 0 { + let row = adw::ActionRow::builder() + .title(&i18n("Comments")) + .subtitle(&format!("{}", comments)) + .build(); + details_group.add(&row); + } + } content.append(&details_group); + // --- Links section --- + let links_group = adw::PreferencesGroup::builder() + .title(&i18n("Links")) + .build(); + + // GitHub link (extracted from description or already known) + if let Some(ref owner) = app.github_owner { + if let Some(ref repo) = app.github_repo { + let gh_url = format!("https://github.com/{}/{}", owner, repo); + let row = adw::ActionRow::builder() + .title(&i18n("GitHub")) + .subtitle(&gh_url) + .subtitle_selectable(true) + .activatable(true) + .build(); + let arrow = gtk::Image::from_icon_name("external-link-symbolic"); + arrow.set_valign(gtk::Align::Center); + row.add_suffix(&arrow); + + let url = gh_url; + row.connect_activated(move |row| { + let launcher = gtk::UriLauncher::new(&url); + let root = row.root().and_downcast::(); + launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); + }); + links_group.add(&row); + } + } + + // AppImageHub.com detail page + if let Some(ref detailpage) = app.ocs_detailpage { + if !detailpage.is_empty() { + let row = adw::ActionRow::builder() + .title(&i18n("AppImageHub")) + .subtitle(detailpage) + .subtitle_selectable(true) + .activatable(true) + .build(); + let arrow = gtk::Image::from_icon_name("external-link-symbolic"); + arrow.set_valign(gtk::Align::Center); + row.add_suffix(&arrow); + + let dp = detailpage.clone(); + row.connect_activated(move |row| { + let launcher = gtk::UriLauncher::new(&dp); + let root = row.root().and_downcast::(); + launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); + }); + links_group.add(&row); + } + } + + // Homepage (non-OCS apps or if we have a real homepage) + if let Some(ref homepage) = app.homepage { + // Don't duplicate if it's the same as the detailpage + let is_detailpage = app.ocs_detailpage.as_deref() == Some(homepage.as_str()); + if !is_detailpage { + let row = adw::ActionRow::builder() + .title(&i18n("Homepage")) + .subtitle(homepage) + .subtitle_selectable(true) + .activatable(true) + .build(); + let arrow = gtk::Image::from_icon_name("external-link-symbolic"); + arrow.set_valign(gtk::Align::Center); + row.add_suffix(&arrow); + + let hp = homepage.clone(); + row.connect_activated(move |row| { + let launcher = gtk::UriLauncher::new(&hp); + let root = row.root().and_downcast::(); + launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {}); + }); + links_group.add(&row); + } + } + + + + content.append(&links_group); + // --- Status section --- let status_group = adw::PreferencesGroup::builder() .title(&i18n("Status")) @@ -524,6 +952,7 @@ pub fn build_catalog_detail_page( clamp.set_child(Some(&content)); scrolled.set_child(Some(&clamp)); toolbar.set_content(Some(&scrolled)); + widgets::apply_pointer_cursors(&toolbar); adw::NavigationPage::builder() .title(&page_title) @@ -533,6 +962,7 @@ pub fn build_catalog_detail_page( } /// Trigger an install from a URL. Handles async download, DB registration, and UI feedback. +/// `ocs_slot` is the 1-based OCS download file slot (default 1). fn do_install( url: String, app_name: String, @@ -540,6 +970,8 @@ fn do_install( toast_overlay: adw::ToastOverlay, db: Rc, widget: gtk::Widget, + ocs_id: Option, + ocs_slot: u32, ) { glib::spawn_future_local(async move { let name = app_name.clone(); @@ -567,7 +999,7 @@ fn do_install( github_link: None, }; - catalog::install_from_catalog(&cat_app, &install_dir) + catalog::install_from_catalog_with_ocs(&cat_app, &install_dir, ocs_id, ocs_slot) .map_err(|e| e.to_string()) }).await; @@ -620,6 +1052,7 @@ fn do_install( /// Populate the install button slot with the appropriate button (plain or split). /// Clears any existing children first, so it can be called to rebuild after enrichment. +/// `ocs_files` - if provided, OCS download files to show in the dropdown. fn populate_install_slot( slot: >k::Box, app_name: &str, @@ -629,6 +1062,8 @@ fn populate_install_slot( toast_overlay: &adw::ToastOverlay, db: &Rc, homepage: Option<&str>, + ocs_id: Option, + ocs_files: &[catalog::OcsDownloadFile], ) { // Clear existing children (e.g. the "Loading..." placeholder) while let Some(child) = slot.first_child() { @@ -643,17 +1078,36 @@ fn populate_install_slot( .unwrap_or(download_url) .to_string(); - if assets.len() > 1 { - // Multiple assets available - use SplitButton with dropdown + // Determine the default OCS slot (first file = newest) + let default_ocs_slot = ocs_files.first().map(|f| f.slot).unwrap_or(1); + + // Combine GitHub assets and OCS files into dropdown options + let has_gh_assets = assets.len() > 1; + let has_ocs_files = ocs_files.len() > 1; + + if has_gh_assets || has_ocs_files { let menu = gio::Menu::new(); + + // OCS version files (each needs slot-based install) + for file in ocs_files { + let label = format_ocs_file_label(file); + // Encode ocs_id and slot as action param: "ocs:{ocs_id}:{slot}" + menu.append(Some(&label), Some(&format!("install.ocs::{}:{}", file.ocs_id, file.slot))); + } + + // GitHub release assets for asset in &assets { let label = format_asset_label(&asset.name, asset.size); - menu.append(Some(&label), Some(&format!("install.asset::{}", asset.url))); + menu.append(Some(&label), Some(&format!("install.gh::{}", asset.url))); + } + + // Fallback to original catalog URL + if has_gh_assets || has_ocs_files { + menu.append( + Some(&i18n("Direct download (original)")), + Some(&format!("install.gh::{}", download_url)), + ); } - menu.append( - Some(&i18n("AppImageHub (original)")), - Some(&format!("install.asset::{}", download_url)), - ); let split_btn = adw::SplitButton::builder() .label(&i18n("Install")) @@ -661,11 +1115,14 @@ fn populate_install_slot( .css_classes(["suggested-action", "pill"]) .build(); + // Default click: install the default version let url_for_click = default_url; let name_for_click = app_name.to_string(); let hp_for_click = homepage.map(|s| s.to_string()); let toast_for_click = toast_overlay.clone(); let db_for_click = db.clone(); + let ocs_id_click = ocs_id; + let default_slot = default_ocs_slot; split_btn.connect_clicked(move |btn| { btn.set_sensitive(false); @@ -677,46 +1134,89 @@ fn populate_install_slot( toast_for_click.clone(), db_for_click.clone(), btn.upcast_ref::().clone(), + ocs_id_click, + default_slot, ); }); let action_group = gio::SimpleActionGroup::new(); - let asset_action = gio::SimpleAction::new("asset", Some(glib::VariantTy::STRING)); - let name_for_asset = app_name.to_string(); - let hp_for_asset = homepage.map(|s| s.to_string()); - let toast_for_asset = toast_overlay.clone(); - let db_for_asset = db.clone(); - let split_ref = split_btn.clone(); - asset_action.connect_activate(move |_, param| { + + // OCS slot-based install action + let ocs_action = gio::SimpleAction::new("ocs", Some(glib::VariantTy::STRING)); + let name_ocs = app_name.to_string(); + let hp_ocs = homepage.map(|s| s.to_string()); + let toast_ocs = toast_overlay.clone(); + let db_ocs = db.clone(); + let split_ocs = split_btn.clone(); + let dl_url_ocs = download_url.to_string(); + ocs_action.connect_activate(move |_, param| { + if let Some(param_str) = param.and_then(|p| p.str()) { + // Parse "ocs_id:slot" + let parts: Vec<&str> = param_str.splitn(2, ':').collect(); + if let (Some(id_str), Some(slot_str)) = (parts.first(), parts.get(1)) { + let ocs_id = id_str.parse::().ok(); + let slot = slot_str.parse::().unwrap_or(1); + split_ocs.set_sensitive(false); + split_ocs.set_label("Installing..."); + do_install( + dl_url_ocs.clone(), + name_ocs.clone(), + hp_ocs.clone(), + toast_ocs.clone(), + db_ocs.clone(), + split_ocs.upcast_ref::().clone(), + ocs_id, + slot, + ); + } + } + }); + action_group.add_action(&ocs_action); + + // GitHub URL-based install action + let gh_action = gio::SimpleAction::new("gh", Some(glib::VariantTy::STRING)); + let name_gh = app_name.to_string(); + let hp_gh = homepage.map(|s| s.to_string()); + let toast_gh = toast_overlay.clone(); + let db_gh = db.clone(); + let split_gh = split_btn.clone(); + gh_action.connect_activate(move |_, param| { if let Some(url) = param.and_then(|p| p.str()) { - split_ref.set_sensitive(false); - split_ref.set_label("Installing..."); + split_gh.set_sensitive(false); + split_gh.set_label("Installing..."); do_install( url.to_string(), - name_for_asset.clone(), - hp_for_asset.clone(), - toast_for_asset.clone(), - db_for_asset.clone(), - split_ref.upcast_ref::().clone(), + name_gh.clone(), + hp_gh.clone(), + toast_gh.clone(), + db_gh.clone(), + split_gh.upcast_ref::().clone(), + None, + 1, ); } }); - action_group.add_action(&asset_action); + action_group.add_action(&gh_action); + split_btn.insert_action_group("install", Some(&action_group)); + widgets::set_pointer_cursor(&split_btn); slot.append(&split_btn); } else { - // Single asset or no GitHub assets - plain button + // Single asset - plain button let install_btn = gtk::Button::builder() .label(&i18n("Install")) .css_classes(["suggested-action", "pill"]) .build(); + widgets::set_pointer_cursor(&install_btn); let url_clone = default_url; let name_clone = app_name.to_string(); let hp_clone = homepage.map(|s| s.to_string()); let toast_clone = toast_overlay.clone(); let db_clone = db.clone(); + let ocs_id_plain = ocs_id; + let slot_plain = default_ocs_slot; install_btn.connect_clicked(move |btn| { btn.set_sensitive(false); @@ -728,6 +1228,8 @@ fn populate_install_slot( toast_clone.clone(), db_clone.clone(), btn.upcast_ref::().clone(), + ocs_id_plain, + slot_plain, ); }); @@ -735,6 +1237,27 @@ fn populate_install_slot( } } +/// Format an OCS download file label for the dropdown menu. +fn format_ocs_file_label(file: &catalog::OcsDownloadFile) -> String { + let mut parts = Vec::new(); + if !file.version.is_empty() { + parts.push(format!("v{}", file.version)); + } + if !file.filename.is_empty() { + parts.push(file.filename.clone()); + } + if let Some(size_kb) = file.size_kb { + if size_kb > 0 { + parts.push(format!("({})", widgets::format_size(size_kb * 1024))); + } + } + if parts.is_empty() { + format!("File {}", file.slot) + } else { + parts.join(" - ") + } +} + /// Format an asset filename with size for the dropdown menu. fn format_asset_label(name: &str, size: i64) -> String { if size > 0 { diff --git a/src/ui/catalog_tile.rs b/src/ui/catalog_tile.rs index df373c0..d7ba49a 100644 --- a/src/ui/catalog_tile.rs +++ b/src/ui/catalog_tile.rs @@ -45,9 +45,11 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild { .build(); inner.append(&name_label); - // Description (always 2 lines for uniform height) - let plain = app.description.as_deref() + // Description (always 2 lines for uniform height) - prefer OCS summary, then GitHub description + let plain = app.ocs_summary.as_deref() .filter(|d| !d.is_empty()) + .or(app.github_description.as_deref().filter(|d| !d.is_empty())) + .or(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(); @@ -139,6 +141,7 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild { .child(&card) .build(); child.add_css_class("activatable"); + widgets::set_pointer_cursor(&child); child } @@ -158,6 +161,7 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box { card.add_css_class("card"); card.add_css_class("catalog-featured-card"); card.add_css_class("activatable"); + widgets::set_pointer_cursor(&card); card.set_widget_name(&format!("featured-{}", app.id)); // Screenshot preview area (top) @@ -212,27 +216,29 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box { .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); - } + // Description (1 line in featured since space is tight) - prefer OCS summary + let feat_desc = app.ocs_summary.as_deref() + .filter(|d| !d.is_empty()) + .or(app.github_description.as_deref().filter(|d| !d.is_empty())) + .or(app.description.as_deref().filter(|d| !d.is_empty())); + if let Some(desc) = feat_desc { + 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 @@ -273,7 +279,71 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box { card } -/// Strip HTML tags from a string, returning plain text. +/// Convert HTML to readable formatted plain text, preserving paragraph breaks, +/// line breaks, and list structure. Suitable for detail page descriptions. +pub fn html_to_description(html: &str) -> String { + let mut result = String::with_capacity(html.len()); + let mut in_tag = false; + let mut tag_buf = String::new(); + + for ch in html.chars() { + match ch { + '<' => { + in_tag = true; + tag_buf.clear(); + } + '>' if in_tag => { + in_tag = false; + let tag = tag_buf.trim().to_lowercase(); + let tag_name = tag.split_whitespace().next().unwrap_or(""); + match tag_name { + "br" | "br/" => result.push('\n'), + "/p" => result.push_str("\n\n"), + "li" => result.push_str("\n - "), + "/ul" | "/ol" => result.push('\n'), + s if s.starts_with("/h") => result.push_str("\n\n"), + _ => {} + } + } + _ if in_tag => tag_buf.push(ch), + _ => result.push(ch), + } + } + + // Decode HTML entities + let decoded = result + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace(" ", " "); + + // Clean up: trim lines, collapse multiple blank lines + let trimmed: Vec<&str> = decoded.lines().map(|l| l.trim()).collect(); + let mut cleaned = String::new(); + let mut prev_blank = false; + for line in &trimmed { + if line.is_empty() { + if !prev_blank && !cleaned.is_empty() { + cleaned.push('\n'); + prev_blank = true; + } + } else { + if prev_blank { + cleaned.push('\n'); + } + cleaned.push_str(line); + cleaned.push('\n'); + prev_blank = false; + } + } + + cleaned.trim().to_string() +} + +/// Strip all HTML tags and collapse whitespace. Suitable for short descriptions on tiles. pub fn strip_html(html: &str) -> String { let mut result = String::with_capacity(html.len()); let mut in_tag = false; diff --git a/src/ui/catalog_view.rs b/src/ui/catalog_view.rs index e2f8209..2dbaa22 100644 --- a/src/ui/catalog_view.rs +++ b/src/ui/catalog_view.rs @@ -5,7 +5,7 @@ use std::rc::Rc; use gtk::gio; use crate::core::catalog; -use crate::core::database::{CatalogApp, Database}; +use crate::core::database::{CatalogApp, CatalogSortOrder, Database}; use crate::i18n::i18n; use super::catalog_detail; use super::catalog_tile; @@ -50,7 +50,6 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: .xalign(0.0) .halign(gtk::Align::Start) .margin_start(18) - .margin_top(6) .build(); // Stack for crossfade page transitions @@ -148,29 +147,75 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: featured_section.append(&featured_label); featured_section.append(&carousel_row); - // --- Category filter chips --- + // --- Category filter tiles (wrapping grid) --- let category_box = gtk::FlowBox::builder() - .selection_mode(gtk::SelectionMode::None) .homogeneous(false) .min_children_per_line(3) - .max_children_per_line(20) - .row_spacing(6) - .column_spacing(6) + .max_children_per_line(6) + .selection_mode(gtk::SelectionMode::None) .margin_start(18) .margin_end(18) - .margin_top(6) + .row_spacing(8) + .column_spacing(8) .build(); - // --- "All Apps" section --- + // --- "All Apps" section header with sort dropdown --- let all_label = gtk::Label::builder() .label(&i18n("All Apps")) .css_classes(["title-2"]) .xalign(0.0) .halign(gtk::Align::Start) - .margin_start(18) - .margin_top(6) + .hexpand(true) .build(); + let sort_options = [ + ("Name (A-Z)", CatalogSortOrder::NameAsc), + ("Name (Z-A)", CatalogSortOrder::NameDesc), + ("Stars (most first)", CatalogSortOrder::StarsDesc), + ("Stars (fewest first)", CatalogSortOrder::StarsAsc), + ("Downloads (most first)", CatalogSortOrder::DownloadsDesc), + ("Downloads (fewest first)", CatalogSortOrder::DownloadsAsc), + ("Release date (newest first)", CatalogSortOrder::ReleaseDateDesc), + ("Release date (oldest first)", CatalogSortOrder::ReleaseDateAsc), + ]; + + let sort_model = gtk::StringList::new( + &sort_options.iter().map(|(label, _)| *label).collect::>(), + ); + let sort_dropdown = gtk::DropDown::builder() + .model(&sort_model) + .selected(0) + .valign(gtk::Align::Center) + .tooltip_text(&i18n("Sort apps")) + .build(); + sort_dropdown.add_css_class("flat"); + + let sort_icon = gtk::Image::builder() + .icon_name("view-sort-descending-symbolic") + .margin_end(4) + .build(); + + let sort_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .valign(gtk::Align::Center) + .build(); + sort_row.append(&sort_icon); + sort_row.append(&sort_dropdown); + + let all_header = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(12) + .margin_start(18) + .margin_end(18) + .build(); + all_header.append(&all_label); + all_header.append(&sort_row); + + // Sort state + let active_sort: Rc> = + Rc::new(std::cell::Cell::new(CatalogSortOrder::NameAsc)); + // FlowBox grid let flow_box = gtk::FlowBox::builder() .homogeneous(true) @@ -191,7 +236,7 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: let content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) - .spacing(8) + .spacing(48) .build(); // Enrichment banner (hidden by default, shown by background enrichment) @@ -200,7 +245,6 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: .spacing(8) .margin_start(18) .margin_end(18) - .margin_top(6) .visible(false) .build(); enrichment_banner.add_css_class("card"); @@ -231,8 +275,8 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: content.append(&search_bar); content.append(&enrichment_banner); content.append(&featured_section); - content.append(&category_box.clone()); - content.append(&all_label); + content.append(&category_box); + content.append(&all_header); content.append(&flow_box); clamp.set_child(Some(&content)); @@ -301,7 +345,7 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: // Populate categories populate_categories( - db, &category_box, &active_category, &flow_box, &search_entry, + db, &category_box, &active_category, &active_sort, &flow_box, &search_entry, &featured_section, &all_label, nav_view, &toast_overlay, ); @@ -310,13 +354,47 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: db, &featured_apps, &featured_page, &featured_stack, &featured_flip, &left_arrow, &right_arrow, nav_view, &toast_overlay, ); - populate_grid(db, "", None, &flow_box, &all_label, nav_view, &toast_overlay); + populate_grid(db, "", None, active_sort.get(), &flow_box, &all_label, nav_view, &toast_overlay); + + // Sort dropdown handler + { + let db_ref = db.clone(); + let flow_ref = flow_box.clone(); + let cat_ref = active_category.clone(); + let sort_ref = active_sort.clone(); + let search_ref = search_entry.clone(); + let all_label_ref = all_label.clone(); + let nav_ref = nav_view.clone(); + let toast_ref = toast_overlay.clone(); + sort_dropdown.connect_selected_notify(move |dd| { + let idx = dd.selected() as usize; + let sort_options_local = [ + CatalogSortOrder::NameAsc, + CatalogSortOrder::NameDesc, + CatalogSortOrder::StarsDesc, + CatalogSortOrder::StarsAsc, + CatalogSortOrder::DownloadsDesc, + CatalogSortOrder::DownloadsAsc, + CatalogSortOrder::ReleaseDateDesc, + CatalogSortOrder::ReleaseDateAsc, + ]; + let sort = sort_options_local.get(idx).copied().unwrap_or(CatalogSortOrder::NameAsc); + sort_ref.set(sort); + let query = search_ref.text().to_string(); + let cat = cat_ref.borrow().clone(); + populate_grid( + &db_ref, &query, cat.as_deref(), sort, + &flow_ref, &all_label_ref, &nav_ref, &toast_ref, + ); + }); + } // Search handler { let db_ref = db.clone(); let flow_ref = flow_box.clone(); let cat_ref = active_category.clone(); + let sort_ref = active_sort.clone(); let nav_ref = nav_view.clone(); let toast_ref = toast_overlay.clone(); let all_label_ref = all_label.clone(); @@ -327,8 +405,8 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: let is_searching = !query.is_empty() || cat.is_some(); featured_section_ref.set_visible(!is_searching); populate_grid( - &db_ref, &query, cat.as_deref(), &flow_ref, - &all_label_ref, &nav_ref, &toast_ref, + &db_ref, &query, cat.as_deref(), sort_ref.get(), + &flow_ref, &all_label_ref, &nav_ref, &toast_ref, ); }); } @@ -361,6 +439,7 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: let title_ref = title.clone(); let cat_box_ref = category_box.clone(); let active_cat_ref = active_category.clone(); + let active_sort_ref = active_sort.clone(); let search_ref = search_entry.clone(); let featured_apps_ref = featured_apps.clone(); let featured_page_ref = featured_page.clone(); @@ -383,6 +462,7 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: let btn_c = btn.clone(); let cat_box_c = cat_box_ref.clone(); let active_cat_c = active_cat_ref.clone(); + let active_sort_c = active_sort_ref.clone(); let search_c = search_ref.clone(); let featured_apps_c = featured_apps_ref.clone(); let featured_page_c = featured_page_ref.clone(); @@ -407,33 +487,69 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: let (tx, rx) = std::sync::mpsc::channel::(); // Listen for progress on the main thread + // Track current source info for progress text let progress_listen = progress_c.clone(); - glib::timeout_add_local(std::time::Duration::from_millis(50), move || { + let current_source_name: Rc> = Rc::new(RefCell::new(String::new())); + let current_source_base: Rc> = Rc::new(std::cell::Cell::new(0.0)); + let current_source_span: Rc> = Rc::new(std::cell::Cell::new(1.0)); + glib::timeout_add_local(std::time::Duration::from_millis(50), { + let src_name = current_source_name.clone(); + let src_base = current_source_base.clone(); + let src_span = current_source_span.clone(); + move || { while let Ok(progress) = rx.try_recv() { match progress { - catalog::SyncProgress::FetchingFeed => { - progress_listen.set_fraction(0.0); - progress_listen.set_text(Some("Fetching catalog feed...")); - } - catalog::SyncProgress::FeedFetched { total } => { - progress_listen.set_fraction(0.05); - progress_listen.set_text(Some(&format!("Found {} apps, caching icons...", total))); - } - catalog::SyncProgress::CachingIcon { current, total, .. } => { - let frac = 0.05 + 0.85 * (current as f64 / total.max(1) as f64); + catalog::SyncProgress::SourceStarted { ref name, source_index, source_count } => { + *src_name.borrow_mut() = name.clone(); + // Divide progress bar evenly across sources + let span = 1.0 / source_count.max(1) as f64; + src_base.set(source_index as f64 * span); + src_span.set(span); + let frac = src_base.get(); progress_listen.set_fraction(frac); progress_listen.set_text(Some( - &format!("Caching icons ({}/{})", current, total), + &format!("Syncing {}...", name), + )); + } + catalog::SyncProgress::FetchingFeed => { + let name = src_name.borrow(); + progress_listen.set_fraction(src_base.get()); + progress_listen.set_text(Some( + &format!("{}: Fetching feed...", &*name), + )); + } + catalog::SyncProgress::FeedFetched { total } => { + let name = src_name.borrow(); + progress_listen.set_fraction(src_base.get() + src_span.get() * 0.05); + progress_listen.set_text(Some( + &format!("{}: Found {} apps", &*name, total), + )); + } + catalog::SyncProgress::CachingIcon { current, total, .. } => { + let name = src_name.borrow(); + let inner = 0.05 + 0.85 * (current as f64 / total.max(1) as f64); + progress_listen.set_fraction(src_base.get() + src_span.get() * inner); + progress_listen.set_text(Some( + &format!("{}: Caching icons ({}/{})", &*name, current, total), )); } catalog::SyncProgress::SavingApps { current, total } => { - let frac = 0.90 + 0.10 * (current as f64 / total.max(1) as f64); - progress_listen.set_fraction(frac); + let name = src_name.borrow(); + let inner = 0.90 + 0.10 * (current as f64 / total.max(1) as f64); + progress_listen.set_fraction(src_base.get() + src_span.get() * inner); progress_listen.set_text(Some( - &format!("Saving apps ({}/{})", current, total), + &format!("{}: Saving ({}/{})", &*name, current, total), )); } catalog::SyncProgress::Done { .. } => { + // Single source done - don't break, more sources may follow + let name = src_name.borrow(); + progress_listen.set_fraction(src_base.get() + src_span.get()); + progress_listen.set_text(Some( + &format!("{}: Complete", &*name), + )); + } + catalog::SyncProgress::AllDone => { progress_listen.set_fraction(1.0); progress_listen.set_text(Some("Complete")); return glib::ControlFlow::Break; @@ -444,7 +560,7 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: return glib::ControlFlow::Break; } glib::ControlFlow::Continue - }); + }}); glib::spawn_future_local(async move { let result = gio::spawn_blocking(move || { @@ -452,13 +568,31 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: if let Some(ref db) = db_bg { catalog::ensure_default_sources(db); let sources = catalog::get_sources(db); - if let Some(source) = sources.first() { - catalog::sync_catalog_with_progress(db, source, &move |p| { - tx.send(p).ok(); - }).map_err(|e| e.to_string()) - } else { - Err("No catalog sources configured".to_string()) + if sources.is_empty() { + return Err("No catalog sources configured".to_string()); } + // Sync all sources in order (OCS first as primary, then secondary) + let enabled_sources: Vec<_> = sources.iter() + .filter(|s| s.enabled) + .collect(); + let source_count = enabled_sources.len() as u32; + let mut total_count = 0u32; + for (i, source) in enabled_sources.iter().enumerate() { + tx.send(catalog::SyncProgress::SourceStarted { + name: source.name.clone(), + source_index: i as u32, + source_count, + }).ok(); + let tx_ref = tx.clone(); + match catalog::sync_catalog_with_progress(db, source, &move |p| { + tx_ref.send(p).ok(); + }) { + Ok(count) => total_count += count, + Err(e) => eprintln!("Failed to sync source '{}': {}", source.name, e), + } + } + tx.send(catalog::SyncProgress::AllDone).ok(); + Ok(total_count) } else { Err("Failed to open database".to_string()) } @@ -480,7 +614,7 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: update_catalog_subtitle(&title_c, count_after); stack_c.set_visible_child_name("results"); populate_categories( - &db_c, &cat_box_c, &active_cat_c, &flow_c, &search_c, + &db_c, &cat_box_c, &active_cat_c, &active_sort_c, &flow_c, &search_c, &featured_section_c, &all_label_c, &nav_c, &toast_c, ); @@ -490,7 +624,7 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: &left_arrow_c, &right_arrow_c, &nav_c, &toast_c, ); populate_grid( - &db_c, "", None, &flow_c, &all_label_c, &nav_c, &toast_c, + &db_c, "", None, active_sort_c.get(), &flow_c, &all_label_c, &nav_c, &toast_c, ); let settings = gio::Settings::new(crate::config::APP_ID); @@ -519,6 +653,8 @@ fn build_browse_page(db: &Rc, nav_view: &adw::NavigationView) -> (adw: refresh_btn.emit_clicked(); } + widgets::apply_pointer_cursors(&toolbar_view); + let page = adw::NavigationPage::builder() .title(&i18n("Catalog")) .tag("catalog-browse") @@ -679,6 +815,7 @@ fn populate_grid( db: &Rc, query: &str, category: Option<&str>, + sort: CatalogSortOrder, flow_box: >k::FlowBox, all_label: >k::Label, _nav_view: &adw::NavigationView, @@ -689,7 +826,7 @@ fn populate_grid( flow_box.remove(&child); } - let results = db.search_catalog(query, category, 200).unwrap_or_default(); + let results = db.search_catalog(query, category, 200, sort).unwrap_or_default(); if results.is_empty() { all_label.set_label(&i18n("No results")); @@ -711,10 +848,54 @@ fn populate_grid( } } +/// Map a FreeDesktop category name to (icon_name, color_css_class). +fn category_meta(name: &str) -> (&'static str, &'static str) { + match name.to_lowercase().as_str() { + "audio" => ("audio-x-generic-symbolic", "cat-purple"), + "audiovideo" | "video" => ("camera-video-symbolic", "cat-red"), + "game" => ("input-gaming-symbolic", "cat-green"), + "graphics" => ("image-x-generic-symbolic", "cat-orange"), + "development" => ("utilities-terminal-symbolic", "cat-blue"), + "education" => ("accessories-dictionary-symbolic", "cat-amber"), + "network" => ("network-workgroup-symbolic", "cat-purple"), + "office" => ("x-office-document-symbolic", "cat-amber"), + "science" => ("accessories-calculator-symbolic", "cat-blue"), + "system" => ("emblem-system-symbolic", "cat-neutral"), + "utility" => ("applications-utilities-symbolic", "cat-green"), + _ => ("application-x-executable-symbolic", "cat-neutral"), + } +} + +/// Build a category tile toggle button with icon and label. +fn build_category_tile(label_text: &str, icon_name: &str, color_class: &str, active: bool) -> gtk::ToggleButton { + let icon = gtk::Image::from_icon_name(icon_name); + icon.set_pixel_size(24); + + let label = gtk::Label::new(Some(label_text)); + label.set_ellipsize(gtk::pango::EllipsizeMode::End); + + let inner = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(10) + .halign(gtk::Align::Center) + .build(); + inner.append(&icon); + inner.append(&label); + + let btn = gtk::ToggleButton::builder() + .child(&inner) + .active(active) + .css_classes(["flat", "category-tile", color_class]) + .build(); + widgets::set_pointer_cursor(&btn); + btn +} + fn populate_categories( db: &Rc, category_box: >k::FlowBox, active_category: &Rc>>, + active_sort: &Rc>, flow_box: >k::FlowBox, search_entry: >k::SearchEntry, featured_section: >k::Box, @@ -732,26 +913,23 @@ fn populate_categories( return; } - let all_btn = gtk::ToggleButton::builder() - .label(&i18n("All")) - .active(true) - .css_classes(["pill"]) - .build(); - category_box.insert(&all_btn, -1); + let all_btn = build_category_tile( + &i18n("All"), "view-grid-symbolic", "cat-accent", true, + ); + category_box.append(&all_btn); let buttons: Rc>> = Rc::new(RefCell::new(vec![all_btn.clone()])); - for (cat, _count) in categories.iter().take(10) { - let btn = gtk::ToggleButton::builder() - .label(cat) - .css_classes(["pill"]) - .build(); - category_box.insert(&btn, -1); + for (cat, _count) in categories.iter().take(12) { + let (icon_name, color_class) = category_meta(cat); + let btn = build_category_tile(cat, icon_name, color_class, false); + category_box.append(&btn); buttons.borrow_mut().push(btn.clone()); let cat_str = cat.clone(); let active_ref = active_category.clone(); + let sort_ref = active_sort.clone(); let flow_ref = flow_box.clone(); let search_ref = search_entry.clone(); let db_ref = db.clone(); @@ -771,8 +949,8 @@ fn populate_categories( featured_section_ref.set_visible(false); let query = search_ref.text().to_string(); populate_grid( - &db_ref, &query, Some(&cat_str), &flow_ref, - &all_label_ref, &nav_ref, &toast_ref, + &db_ref, &query, Some(&cat_str), sort_ref.get(), + &flow_ref, &all_label_ref, &nav_ref, &toast_ref, ); } }); @@ -780,6 +958,7 @@ fn populate_categories( { let active_ref = active_category.clone(); + let sort_ref = active_sort.clone(); let flow_ref = flow_box.clone(); let search_ref = search_entry.clone(); let db_ref = db.clone(); @@ -799,8 +978,8 @@ fn populate_categories( featured_section_ref.set_visible(true); let query = search_ref.text().to_string(); populate_grid( - &db_ref, &query, None, &flow_ref, - &all_label_ref, &nav_ref, &toast_ref, + &db_ref, &query, None, sort_ref.get(), + &flow_ref, &all_label_ref, &nav_ref, &toast_ref, ); } }); diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index c6a0e98..09fac23 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -69,6 +69,7 @@ pub fn build_dashboard_page(db: &Rc) -> adw::NavigationPage { let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); toolbar.set_content(Some(&scrolled)); + widgets::apply_pointer_cursors(&toolbar); adw::NavigationPage::builder() .title("Dashboard") diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index 03e647b..1a61969 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -197,6 +197,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); toolbar.set_content(Some(&toast_overlay)); + widgets::apply_pointer_cursors(&toolbar); adw::NavigationPage::builder() .title(name) diff --git a/src/ui/library_view.rs b/src/ui/library_view.rs index e273b1f..6679a5b 100644 --- a/src/ui/library_view.rs +++ b/src/ui/library_view.rs @@ -359,6 +359,7 @@ impl LibraryView { let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header_bar); toolbar_view.set_content(Some(&content_box)); + widgets::apply_pointer_cursors(&toolbar_view); let page = adw::NavigationPage::builder() .title("Driftwood") diff --git a/src/ui/preferences.rs b/src/ui/preferences.rs index a1f62c3..f853abb 100644 --- a/src/ui/preferences.rs +++ b/src/ui/preferences.rs @@ -14,6 +14,7 @@ pub fn show_preferences_dialog(parent: &impl IsA) { dialog.add(&build_general_page(&settings, &dialog)); dialog.add(&build_updates_page(&settings)); + super::widgets::apply_pointer_cursors(&dialog); dialog.present(Some(parent)); } diff --git a/src/ui/updates_view.rs b/src/ui/updates_view.rs index 75470fb..ff2f721 100644 --- a/src/ui/updates_view.rs +++ b/src/ui/updates_view.rs @@ -197,6 +197,7 @@ pub fn build_updates_view(db: &Rc) -> adw::ToolbarView { let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header); toolbar_view.set_content(Some(&toast_overlay)); + widgets::apply_pointer_cursors(&toolbar_view); toolbar_view } diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index b27a59c..e8e0ddb 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -55,6 +55,37 @@ fn generate_letter_icon_css() -> String { css } +/// Set the pointer (hand) cursor on a widget, so it looks clickable on hover. +pub fn set_pointer_cursor(widget: &impl IsA) { + widget.as_ref().set_cursor_from_name(Some("pointer")); +} + +/// Recursively walk a widget tree and set pointer cursor on all interactive elements. +/// Call this on a view's root container after building it to cover buttons, switches, +/// toggle buttons, activatable rows, and other clickable widgets. +pub fn apply_pointer_cursors(widget: &impl IsA) { + let w = widget.as_ref(); + + let is_interactive = w.is::() + || w.is::() + || w.is::() + || w.is::() + || w.is::() + || w.is::() + || w.is::() + || w.has_css_class("activatable"); + + if is_interactive { + w.set_cursor_from_name(Some("pointer")); + } + + let mut child = w.first_child(); + while let Some(c) = child { + apply_pointer_cursors(&c); + child = c.next_sibling(); + } +} + /// Create a status badge pill label with the given text and style class. /// Style classes: "success", "warning", "error", "info", "neutral" pub fn status_badge(text: &str, style_class: &str) -> gtk::Label { @@ -439,3 +470,163 @@ pub fn announce(container: &impl gtk::prelude::IsA, text: &str) { }); } } + +/// Build a GTK widget tree from markdown text using pulldown-cmark. +/// Returns a vertical Box containing formatted labels for each block element. +pub fn build_markdown_view(markdown: &str) -> gtk::Box { + use pulldown_cmark::{Event, Tag, TagEnd, Options, Parser}; + + let container = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build(); + + let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES; + let parser = Parser::new_ext(markdown, options); + + // Accumulate inline Pango markup, flush as labels on block boundaries + let mut markup = String::new(); + let mut in_heading: Option = None; + let mut in_code_block = false; + let mut code_block_text = String::new(); + let mut list_depth: u32 = 0; + let mut list_item_open = false; + + let flush_label = |container: >k::Box, markup: &mut String, heading: Option| { + let text = markup.trim().to_string(); + if text.is_empty() { + markup.clear(); + return; + } + let label = gtk::Label::builder() + .use_markup(true) + .wrap(true) + .wrap_mode(gtk::pango::WrapMode::WordChar) + .xalign(0.0) + .halign(gtk::Align::Fill) + .selectable(true) + .build(); + + label.set_markup(&text); + + match heading { + Some(1) => { label.add_css_class("title-1"); label.set_margin_top(12); } + Some(2) => { label.add_css_class("title-2"); label.set_margin_top(10); } + Some(3) => { label.add_css_class("title-3"); label.set_margin_top(8); } + Some(4..=6) => { label.add_css_class("title-4"); label.set_margin_top(6); } + _ => {} + } + container.append(&label); + markup.clear(); + }; + + for event in parser { + match event { + Event::Start(Tag::Heading { level, .. }) => { + flush_label(&container, &mut markup, None); + in_heading = Some(level as u8); + } + Event::End(TagEnd::Heading(_)) => { + let level = in_heading.take(); + flush_label(&container, &mut markup, level); + } + Event::Start(Tag::Paragraph) => {} + Event::End(TagEnd::Paragraph) => { + if !in_code_block { + flush_label(&container, &mut markup, None); + } + } + Event::Start(Tag::CodeBlock(_)) => { + flush_label(&container, &mut markup, None); + in_code_block = true; + code_block_text.clear(); + } + Event::End(TagEnd::CodeBlock) => { + in_code_block = false; + let code_label = gtk::Label::builder() + .use_markup(true) + .wrap(true) + .wrap_mode(gtk::pango::WrapMode::WordChar) + .xalign(0.0) + .halign(gtk::Align::Fill) + .selectable(true) + .css_classes(["monospace", "card"]) + .margin_start(8) + .margin_end(8) + .build(); + // Escape for Pango markup inside the tag + let escaped = glib::markup_escape_text(&code_block_text); + code_label.set_markup(&format!("{}", escaped)); + container.append(&code_label); + code_block_text.clear(); + } + Event::Start(Tag::Strong) => markup.push_str(""), + Event::End(TagEnd::Strong) => markup.push_str(""), + Event::Start(Tag::Emphasis) => markup.push_str(""), + Event::End(TagEnd::Emphasis) => markup.push_str(""), + Event::Start(Tag::Strikethrough) => markup.push_str(""), + Event::End(TagEnd::Strikethrough) => markup.push_str(""), + Event::Start(Tag::Link { dest_url, .. }) => { + markup.push_str(&format!("", glib::markup_escape_text(&dest_url))); + } + Event::End(TagEnd::Link) => markup.push_str(""), + Event::Start(Tag::List(_)) => { + flush_label(&container, &mut markup, None); + list_depth += 1; + } + Event::End(TagEnd::List(_)) => { + flush_label(&container, &mut markup, None); + list_depth = list_depth.saturating_sub(1); + } + Event::Start(Tag::Item) => { + list_item_open = true; + let indent = " ".repeat(list_depth.saturating_sub(1) as usize); + markup.push_str(&format!("{} \u{2022} ", indent)); + } + Event::End(TagEnd::Item) => { + list_item_open = false; + flush_label(&container, &mut markup, None); + } + Event::Code(code) => { + markup.push_str(&format!("{}", glib::markup_escape_text(&code))); + } + Event::Text(text) => { + if in_code_block { + code_block_text.push_str(&text); + } else { + markup.push_str(&glib::markup_escape_text(&text)); + } + } + Event::SoftBreak => { + if in_code_block { + code_block_text.push('\n'); + } else if list_item_open { + markup.push(' '); + } else { + markup.push(' '); + } + } + Event::HardBreak => { + if in_code_block { + code_block_text.push('\n'); + } else { + markup.push('\n'); + } + } + Event::Rule => { + flush_label(&container, &mut markup, None); + let sep = gtk::Separator::new(gtk::Orientation::Horizontal); + sep.set_margin_top(8); + sep.set_margin_bottom(8); + container.append(&sep); + } + // Skip images, HTML, footnotes, etc. + _ => {} + } + } + + // Flush any remaining text + flush_label(&container, &mut markup, None); + + container +}