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
This commit is contained in:
lashman
2026-02-28 20:33:40 +02:00
parent f89aafca6a
commit 4b939f044a
16 changed files with 2394 additions and 417 deletions

41
Cargo.lock generated
View File

@@ -559,6 +559,7 @@ dependencies = [
"log", "log",
"notify", "notify",
"notify-rust", "notify-rust",
"pulldown-cmark",
"quick-xml", "quick-xml",
"rusqlite", "rusqlite",
"serde", "serde",
@@ -876,6 +877,15 @@ dependencies = [
"version_check", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -1804,6 +1814,25 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.5" version = "0.37.5"
@@ -2335,12 +2364,24 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"

View File

@@ -51,5 +51,8 @@ notify-rust = "4"
# File system watching (inotify) # File system watching (inotify)
notify = "7" notify = "7"
# Markdown parsing (for GitHub README rendering)
pulldown-cmark = "0.12"
[build-dependencies] [build-dependencies]
glib-build-tools = "0.22" glib-build-tools = "0.22"

View File

@@ -175,6 +175,58 @@ button.flat:not(.pill):not(.suggested-action):not(.destructive-action) {
min-height: 24px; 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 Cards ===== */
.catalog-tile { .catalog-tile {
border: 1px solid alpha(@window_fg_color, 0.12); border: 1px solid alpha(@window_fg_color, 0.12);

View File

@@ -20,6 +20,7 @@ pub struct CatalogSource {
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum CatalogType { pub enum CatalogType {
AppImageHub, AppImageHub,
OcsAppImageHub,
GitHubSearch, GitHubSearch,
Custom, Custom,
} }
@@ -28,6 +29,7 @@ impl CatalogType {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
Self::AppImageHub => "appimage-hub", Self::AppImageHub => "appimage-hub",
Self::OcsAppImageHub => "ocs-appimagehub",
Self::GitHubSearch => "github-search", Self::GitHubSearch => "github-search",
Self::Custom => "custom", Self::Custom => "custom",
} }
@@ -36,6 +38,7 @@ impl CatalogType {
pub fn from_str(s: &str) -> Self { pub fn from_str(s: &str) -> Self {
match s { match s {
"appimage-hub" => Self::AppImageHub, "appimage-hub" => Self::AppImageHub,
"ocs-appimagehub" => Self::OcsAppImageHub,
"github-search" => Self::GitHubSearch, "github-search" => Self::GitHubSearch,
_ => Self::Custom, _ => Self::Custom,
} }
@@ -63,10 +66,138 @@ pub struct CatalogApp {
/// Default AppImageHub registry URL. /// Default AppImageHub registry URL.
const APPIMAGEHUB_API_URL: &str = "https://appimage.github.io/feed.json"; 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<Option<i64>, 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<Option<i32>, 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<OcsItem>,
}
#[derive(Debug, serde::Deserialize)]
struct OcsItem {
#[serde(deserialize_with = "deserialize_lenient_i64", default)]
id: Option<i64>,
#[serde(default)]
name: String,
description: Option<String>,
summary: Option<String>,
downloads: Option<String>,
#[serde(deserialize_with = "deserialize_lenient_i32", default)]
score: Option<i32>,
typename: Option<String>,
personid: Option<String>,
version: Option<String>,
tags: Option<String>,
changed: Option<String>,
created: Option<String>,
previewpic1: Option<String>,
previewpic2: Option<String>,
previewpic3: Option<String>,
previewpic4: Option<String>,
previewpic5: Option<String>,
previewpic6: Option<String>,
smallpreviewpic1: Option<String>,
downloadlink1: Option<String>,
downloadname1: Option<String>,
#[serde(deserialize_with = "deserialize_lenient_i64", default)]
downloadsize1: Option<i64>,
download_package_arch1: Option<String>,
download_package_type1: Option<String>,
downloadmd5sum1: Option<String>,
detailpage: Option<String>,
#[serde(deserialize_with = "deserialize_lenient_i64", default)]
comments: Option<i64>,
}
/// Extra OCS-specific metadata not in the in-memory CatalogApp.
struct OcsExtra {
ocs_id: i64,
ocs_downloads: Option<i64>,
ocs_score: Option<i64>,
ocs_typename: Option<String>,
ocs_personid: Option<String>,
ocs_description: Option<String>,
ocs_summary: Option<String>,
ocs_version: Option<String>,
ocs_tags: Option<String>,
ocs_changed: Option<String>,
ocs_preview_url: Option<String>,
ocs_detailpage: Option<String>,
ocs_created: Option<String>,
ocs_downloadname: Option<String>,
ocs_downloadsize: Option<i64>,
ocs_arch: Option<String>,
ocs_md5sum: Option<String>,
ocs_comments: Option<i64>,
}
/// OCS download resolution response.
#[derive(Debug, serde::Deserialize)]
struct OcsDownloadResponse {
data: Vec<OcsDownloadItem>,
}
#[derive(Debug, serde::Deserialize)]
struct OcsDownloadItem {
#[serde(rename = "downloadlink")]
download_link: Option<String>,
}
/// 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<i64>,
pub arch: Option<String>,
pub pkg_type: Option<String>,
}
/// Sync a catalog source - fetch the index and store entries in the database. /// Sync a catalog source - fetch the index and store entries in the database.
/// Progress updates sent during catalog sync. /// Progress updates sent during catalog sync.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum SyncProgress { 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. /// Fetching the feed from the remote source.
FetchingFeed, FetchingFeed,
/// Feed fetched, total number of apps found. /// Feed fetched, total number of apps found.
@@ -75,8 +206,10 @@ pub enum SyncProgress {
CachingIcon { current: u32, total: u32, app_name: String }, CachingIcon { current: u32, total: u32, app_name: String },
/// Saving apps to the database. /// Saving apps to the database.
SavingApps { current: u32, total: u32 }, SavingApps { current: u32, total: u32 },
/// Sync complete. /// Single source sync complete.
Done { total: u32 }, Done { total: u32 },
/// All sources finished syncing.
AllDone,
} }
pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result<u32, CatalogError> { pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result<u32, CatalogError> {
@@ -90,6 +223,13 @@ pub fn sync_catalog_with_progress(
) -> Result<u32, CatalogError> { ) -> Result<u32, CatalogError> {
on_progress(SyncProgress::FetchingFeed); on_progress(SyncProgress::FetchingFeed);
match source.source_type {
CatalogType::OcsAppImageHub => {
return sync_ocs_catalog(db, source, on_progress);
}
_ => {}
}
let apps = match source.source_type { let apps = match source.source_type {
CatalogType::AppImageHub => fetch_appimage_hub()?, CatalogType::AppImageHub => fetch_appimage_hub()?,
CatalogType::Custom => fetch_custom_catalog(&source.url)?, 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"); log::warn!("GitHub catalog search not yet implemented");
Vec::new() Vec::new()
} }
CatalogType::OcsAppImageHub => unreachable!(),
}; };
let total = apps.len() as u32; let total = apps.len() as u32;
@@ -107,9 +248,18 @@ pub fn sync_catalog_with_progress(
log::info!("Cached {} catalog icons", icon_count); log::info!("Cached {} catalog icons", icon_count);
let source_id = source.id.ok_or(CatalogError::NoSourceId)?; 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; let mut count = 0u32;
for app in &apps { 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; count += 1;
on_progress(SyncProgress::SavingApps { current: count, total }); 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. /// 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<PathBuf, CatalogError> { pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result<PathBuf, CatalogError> {
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<i64>,
ocs_slot: u32,
) -> Result<PathBuf, CatalogError> {
fs::create_dir_all(install_dir).map_err(|e| CatalogError::Io(e.to_string()))?; fs::create_dir_all(install_dir).map_err(|e| CatalogError::Io(e.to_string()))?;
// Derive filename from URL // For OCS apps, resolve a fresh download URL (JWT links expire)
let filename = app.download_url 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('/') .rsplit('/')
.next() .next()
.unwrap_or("downloaded.AppImage"); .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() .call()
.map_err(|e| CatalogError::Network(e.to_string()))?; .map_err(|e| CatalogError::Network(e.to_string()))?;
@@ -195,6 +379,419 @@ pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result<Path
Ok(dest) Ok(dest)
} }
/// Get all app names from the OCS source for deduplication.
fn get_ocs_source_names(db: &Database) -> std::collections::HashSet<String> {
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<u32, CatalogError> {
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<CatalogApp> = 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<Vec<(CatalogApp, OcsExtra)>, 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::<i64>().ok());
// previewpic1 is always the app icon/logo on appimagehub.com.
// Actual screenshots start from previewpic2 onward.
let screenshots: Vec<String> = [
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<String> {
// 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<String> {
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<String, CatalogError> {
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<Vec<OcsDownloadFile>, 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. /// Fetch the AppImageHub feed and parse it into CatalogApp entries.
fn fetch_appimage_hub() -> Result<Vec<CatalogApp>, CatalogError> { fn fetch_appimage_hub() -> Result<Vec<CatalogApp>, CatalogError> {
let response = ureq::get(APPIMAGEHUB_API_URL) let response = ureq::get(APPIMAGEHUB_API_URL)
@@ -273,8 +870,18 @@ fn fetch_custom_catalog(url: &str) -> Result<Vec<CatalogApp>, CatalogError> {
}).collect()) }).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) { 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( db.upsert_catalog_source(
"AppImageHub", "AppImageHub",
APPIMAGEHUB_API_URL, APPIMAGEHUB_API_URL,
@@ -319,6 +926,45 @@ pub fn screenshot_cache_dir() -> PathBuf {
dir 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). /// Resolve an asset path to a full URL (handles relative paths from AppImageHub).
fn resolve_asset_url(path: &str) -> String { fn resolve_asset_url(path: &str) -> String {
if path.starts_with("http://") || path.starts_with("https://") { if path.starts_with("http://") || path.starts_with("https://") {
@@ -492,6 +1138,7 @@ mod tests {
#[test] #[test]
fn test_catalog_type_roundtrip() { fn test_catalog_type_roundtrip() {
assert_eq!(CatalogType::from_str("appimage-hub"), CatalogType::AppImageHub); 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("github-search"), CatalogType::GitHubSearch);
assert_eq!(CatalogType::from_str("custom"), CatalogType::Custom); assert_eq!(CatalogType::from_str("custom"), CatalogType::Custom);
assert_eq!(CatalogType::from_str("unknown"), CatalogType::Custom); assert_eq!(CatalogType::from_str("unknown"), CatalogType::Custom);
@@ -500,6 +1147,7 @@ mod tests {
#[test] #[test]
fn test_catalog_type_as_str() { fn test_catalog_type_as_str() {
assert_eq!(CatalogType::AppImageHub.as_str(), "appimage-hub"); 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::GitHubSearch.as_str(), "github-search");
assert_eq!(CatalogType::Custom.as_str(), "custom"); assert_eq!(CatalogType::Custom.as_str(), "custom");
} }
@@ -517,9 +1165,12 @@ mod tests {
let db = crate::core::database::Database::open_in_memory().unwrap(); let db = crate::core::database::Database::open_in_memory().unwrap();
ensure_default_sources(&db); ensure_default_sources(&db);
let sources = get_sources(&db); let sources = get_sources(&db);
assert_eq!(sources.len(), 1); assert_eq!(sources.len(), 2);
assert_eq!(sources[0].name, "AppImageHub"); let ocs = sources.iter().find(|s| s.source_type == CatalogType::OcsAppImageHub);
assert_eq!(sources[0].source_type, CatalogType::AppImageHub); 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] #[test]

View File

@@ -88,6 +88,33 @@ pub struct SystemModification {
pub previous_value: Option<String>, pub previous_value: Option<String>,
} }
#[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)] #[derive(Debug, Clone)]
pub struct CatalogApp { pub struct CatalogApp {
pub id: i64, pub id: i64,
@@ -108,6 +135,27 @@ pub struct CatalogApp {
pub github_enriched_at: Option<String>, pub github_enriched_at: Option<String>,
pub github_download_url: Option<String>, pub github_download_url: Option<String>,
pub github_release_assets: Option<String>, pub github_release_assets: Option<String>,
pub github_description: Option<String>,
pub github_readme: Option<String>,
// OCS (appimagehub.com) metadata
pub ocs_id: Option<i64>,
pub ocs_downloads: Option<i64>,
pub ocs_score: Option<i64>,
pub ocs_typename: Option<String>,
pub ocs_personid: Option<String>,
pub ocs_description: Option<String>,
pub ocs_summary: Option<String>,
pub ocs_version: Option<String>,
pub ocs_tags: Option<String>,
pub ocs_changed: Option<String>,
pub ocs_preview_url: Option<String>,
pub ocs_detailpage: Option<String>,
pub ocs_created: Option<String>,
pub ocs_downloadname: Option<String>,
pub ocs_downloadsize: Option<i64>,
pub ocs_arch: Option<String>,
pub ocs_md5sum: Option<String>,
pub ocs_comments: Option<i64>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -426,6 +474,18 @@ impl Database {
self.migrate_to_v15()?; 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 // Ensure all expected columns exist (repairs DBs where a migration
// was updated after it had already run on this database) // was updated after it had already run on this database)
self.ensure_columns()?; self.ensure_columns()?;
@@ -930,6 +990,68 @@ impl Database {
Ok(()) 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( pub fn upsert_appimage(
&self, &self,
path: &str, path: &str,
@@ -1095,6 +1217,57 @@ impl Database {
previous_version_path, source_url, autostart, startup_wm_class, previous_version_path, source_url, autostart, startup_wm_class,
verification_status, first_run_prompted, system_wide, is_portable, mount_point"; 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<CatalogApp> {
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<AppImageRecord> { fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result<AppImageRecord> {
Ok(AppImageRecord { Ok(AppImageRecord {
id: row.get(0)?, id: row.get(0)?,
@@ -2159,16 +2332,16 @@ impl Database {
query: &str, query: &str,
category: Option<&str>, category: Option<&str>,
limit: i32, limit: i32,
sort: CatalogSortOrder,
) -> SqlResult<Vec<CatalogApp>> { ) -> SqlResult<Vec<CatalogApp>> {
let mut sql = String::from( let mut sql = format!(
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, "SELECT {} FROM catalog_apps WHERE 1=1",
github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets Self::CATALOG_APP_COLUMNS
FROM catalog_apps WHERE 1=1"
); );
let mut params_list: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new(); let mut params_list: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if !query.is_empty() { 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))); params_list.push(Box::new(format!("%{}%", query)));
} }
@@ -2178,34 +2351,13 @@ impl Database {
params_list.push(Box::new(format!("%{}%", cat))); 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> = let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_list.iter().map(|p| p.as_ref()).collect(); params_list.iter().map(|p| p.as_ref()).collect();
let mut stmt = self.conn.prepare(&sql)?; let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map(params_refs.as_slice(), |row| { let rows = stmt.query_map(params_refs.as_slice(), Self::catalog_app_from_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 mut results = Vec::new(); let mut results = Vec::new();
for row in rows { for row in rows {
@@ -2215,34 +2367,11 @@ impl Database {
} }
pub fn get_catalog_app(&self, id: i64) -> SqlResult<Option<CatalogApp>> { pub fn get_catalog_app(&self, id: i64) -> SqlResult<Option<CatalogApp>> {
let result = self.conn.query_row( let sql = format!(
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, "SELECT {} FROM catalog_apps WHERE id = ?1",
github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets Self::CATALOG_APP_COLUMNS
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 result = self.conn.query_row(&sql, params![id], Self::catalog_app_from_row);
match result { match result {
Ok(app) => Ok(Some(app)), Ok(app) => Ok(Some(app)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), 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), /// Get featured catalog apps. Apps with enrichment data (stars or OCS downloads)
/// then unenriched apps get a deterministic shuffle that rotates every 15 minutes. /// 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<Vec<CatalogApp>> { pub fn get_featured_catalog_apps(&self, limit: i32) -> SqlResult<Vec<CatalogApp>> {
// Time seed rotates every 15 minutes (900 seconds) // Time seed rotates every 15 minutes (900 seconds)
let time_seed = std::time::SystemTime::now() let time_seed = std::time::SystemTime::now()
@@ -2259,46 +2389,31 @@ impl Database {
.unwrap_or_default() .unwrap_or_default()
.as_secs() / 900; .as_secs() / 900;
let mut stmt = self.conn.prepare( let sql = format!(
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, "SELECT {} FROM catalog_apps
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 icon_url IS NOT NULL AND icon_url != '' WHERE icon_url IS NOT NULL AND icon_url != ''
AND description IS NOT NULL AND description != '' AND (description IS NOT NULL AND description != ''
AND screenshots IS NOT NULL AND screenshots != ''" OR ocs_summary IS NOT NULL AND ocs_summary != '')
)?; AND (screenshots IS NOT NULL AND screenshots != ''
let rows = stmt.query_map([], |row| { OR ocs_preview_url IS NOT NULL AND ocs_preview_url != '')",
Ok(CatalogApp { Self::CATALOG_APP_COLUMNS
id: row.get(0)?, );
name: row.get(1)?, let mut stmt = self.conn.prepare(&sql)?;
description: row.get(2)?, let rows = stmt.query_map([], Self::catalog_app_from_row)?;
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 mut apps: Vec<CatalogApp> = rows.collect::<SqlResult<Vec<_>>>()?; let mut apps: Vec<CatalogApp> = rows.collect::<SqlResult<Vec<_>>>()?;
// Enriched apps (with stars) sort first by stars descending, // Sort by combined popularity: OCS downloads + GitHub stars.
// unenriched apps get the deterministic shuffle after them // Apps with any enrichment sort first, then deterministic shuffle.
apps.sort_by(|a, b| { apps.sort_by(|a, b| {
match (a.github_stars, b.github_stars) { let a_pop = a.ocs_downloads.unwrap_or(0) + a.github_stars.unwrap_or(0);
(Some(sa), Some(sb)) => sb.cmp(&sa), let b_pop = b.ocs_downloads.unwrap_or(0) + b.github_stars.unwrap_or(0);
(Some(_), None) => std::cmp::Ordering::Less, let a_enriched = a_pop > 0;
(None, Some(_)) => std::cmp::Ordering::Greater, let b_enriched = b_pop > 0;
(None, None) => { 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 ha = (a.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
let hb = (b.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95); let hb = (b.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
ha.cmp(&hb) ha.cmp(&hb)
@@ -2369,6 +2484,97 @@ impl Database {
Ok(()) 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<i64>,
ocs_score: Option<i64>,
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<i64>,
ocs_arch: Option<&str>,
ocs_md5sum: Option<&str>,
ocs_comments: Option<i64>,
) -> 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<std::collections::HashSet<String>> {
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<Vec<CatalogAppRecord>> { pub fn search_catalog_apps(&self, query: &str) -> SqlResult<Vec<CatalogAppRecord>> {
let pattern = format!("%{}%", query); let pattern = format!("%{}%", query);
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
@@ -2453,10 +2659,11 @@ impl Database {
app_id: i64, app_id: i64,
stars: i64, stars: i64,
pushed_at: Option<&str>, pushed_at: Option<&str>,
description: Option<&str>,
) -> SqlResult<()> { ) -> SqlResult<()> {
self.conn.execute( self.conn.execute(
"UPDATE catalog_apps SET github_stars = ?2, github_enriched_at = datetime('now') WHERE id = ?1", "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], params![app_id, stars, description],
)?; )?;
// Store pushed_at in release_date if no release info yet // Store pushed_at in release_date if no release info yet
if let Some(pushed) = pushed_at { if let Some(pushed) = pushed_at {
@@ -2492,36 +2699,15 @@ impl Database {
} }
pub fn get_unenriched_catalog_apps(&self, limit: i32) -> SqlResult<Vec<CatalogApp>> { pub fn get_unenriched_catalog_apps(&self, limit: i32) -> SqlResult<Vec<CatalogApp>> {
let mut stmt = self.conn.prepare( let sql = format!(
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots, "SELECT {} FROM catalog_apps
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 github_owner IS NOT NULL AND github_enriched_at IS NULL WHERE github_owner IS NOT NULL AND github_enriched_at IS NULL
ORDER BY id ORDER BY id
LIMIT ?1" LIMIT ?1",
)?; Self::CATALOG_APP_COLUMNS
let rows = stmt.query_map(params![limit], |row| { );
Ok(CatalogApp { let mut stmt = self.conn.prepare(&sql)?;
id: row.get(0)?, let rows = stmt.query_map(params![limit], Self::catalog_app_from_row)?;
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)?,
})
})?;
rows.collect() rows.collect()
} }
@@ -2538,6 +2724,18 @@ impl Database {
)?; )?;
Ok((enriched, total_with_github)) 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)] #[cfg(test)]
@@ -2708,7 +2906,7 @@ mod tests {
[], [],
|row| row.get(0), |row| row.get(0),
).unwrap(); ).unwrap();
assert_eq!(version, 15); assert_eq!(version, 18);
// All tables that should exist after the full v1-v7 migration chain // All tables that should exist after the full v1-v7 migration chain
let expected_tables = [ let expected_tables = [

View File

@@ -110,6 +110,54 @@ pub fn fetch_release_info(owner: &str, repo: &str, token: &str) -> Result<(GitHu
Ok((info, remaining)) 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<Vec<u8>, 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 --- // --- AppImage asset filtering ---
/// A simplified release asset for storage (JSON-serializable). /// A simplified release asset for storage (JSON-serializable).
@@ -163,7 +211,7 @@ pub fn pick_best_asset(assets: &[AppImageAsset]) -> Option<&AppImageAsset> {
// --- Enrichment logic --- // --- 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( pub fn enrich_app_repo_info(
db: &Database, db: &Database,
app_id: i64, app_id: i64,
@@ -172,8 +220,9 @@ pub fn enrich_app_repo_info(
token: &str, token: &str,
) -> Result<u32, String> { ) -> Result<u32, String> {
let (info, remaining) = fetch_repo_info(owner, repo, token)?; 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()) db.update_catalog_app_github_metadata(
.map_err(|e| format!("DB error: {}", e))?; app_id, info.stargazers_count, info.pushed_at.as_deref(), info.description.as_deref(),
).map_err(|e| format!("DB error: {}", e))?;
Ok(remaining) Ok(remaining)
} }
@@ -216,6 +265,20 @@ pub fn enrich_app_release_info(
Ok(remaining) 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<u32, String> {
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. /// Background enrichment: process a batch of unenriched apps.
/// Returns (count_enriched, should_continue). /// Returns (count_enriched, should_continue).
pub fn background_enrich_batch( pub fn background_enrich_batch(
@@ -261,7 +324,7 @@ pub fn background_enrich_batch(
Err(e) => { Err(e) => {
log::warn!("Failed to enrich {}/{}: {}", owner, repo, e); log::warn!("Failed to enrich {}/{}: {}", owner, repo, e);
// Mark as enriched anyway so we don't retry forever // 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();
} }
} }

View File

@@ -101,6 +101,7 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
.child(&card) .child(&card)
.build(); .build();
child.add_css_class("activatable"); child.add_css_class("activatable");
super::widgets::set_pointer_cursor(&child);
// Accessible label for screen readers // Accessible label for screen readers
let accessible_name = build_accessible_label(record); let accessible_name = build_accessible_label(record);

File diff suppressed because it is too large Load Diff

View File

@@ -45,9 +45,11 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
.build(); .build();
inner.append(&name_label); inner.append(&name_label);
// Description (always 2 lines for uniform height) // Description (always 2 lines for uniform height) - prefer OCS summary, then GitHub description
let plain = app.description.as_deref() let plain = app.ocs_summary.as_deref()
.filter(|d| !d.is_empty()) .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)) .map(|d| strip_html(d))
.unwrap_or_default(); .unwrap_or_default();
let snippet: String = plain.chars().take(80).collect(); let snippet: String = plain.chars().take(80).collect();
@@ -139,6 +141,7 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
.child(&card) .child(&card)
.build(); .build();
child.add_css_class("activatable"); child.add_css_class("activatable");
widgets::set_pointer_cursor(&child);
child child
} }
@@ -158,6 +161,7 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
card.add_css_class("card"); card.add_css_class("card");
card.add_css_class("catalog-featured-card"); card.add_css_class("catalog-featured-card");
card.add_css_class("activatable"); card.add_css_class("activatable");
widgets::set_pointer_cursor(&card);
card.set_widget_name(&format!("featured-{}", app.id)); card.set_widget_name(&format!("featured-{}", app.id));
// Screenshot preview area (top) // Screenshot preview area (top)
@@ -212,27 +216,29 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
.build(); .build();
text_box.append(&name_label); text_box.append(&name_label);
// Description (1 line in featured since space is tight) // Description (1 line in featured since space is tight) - prefer OCS summary
if let Some(ref desc) = app.description { let feat_desc = app.ocs_summary.as_deref()
if !desc.is_empty() { .filter(|d| !d.is_empty())
let plain = strip_html(desc); .or(app.github_description.as_deref().filter(|d| !d.is_empty()))
let snippet: String = plain.chars().take(60).collect(); .or(app.description.as_deref().filter(|d| !d.is_empty()));
let text = if snippet.len() < plain.len() { if let Some(desc) = feat_desc {
format!("{}...", snippet.trim_end()) let plain = strip_html(desc);
} else { let snippet: String = plain.chars().take(60).collect();
snippet let text = if snippet.len() < plain.len() {
}; format!("{}...", snippet.trim_end())
let desc_label = gtk::Label::builder() } else {
.label(&text) snippet
.css_classes(["caption", "dim-label"]) };
.ellipsize(gtk::pango::EllipsizeMode::End) let desc_label = gtk::Label::builder()
.lines(1) .label(&text)
.xalign(0.0) .css_classes(["caption", "dim-label"])
.max_width_chars(35) .ellipsize(gtk::pango::EllipsizeMode::End)
.halign(gtk::Align::Start) .lines(1)
.build(); .xalign(0.0)
text_box.append(&desc_label); .max_width_chars(35)
} .halign(gtk::Align::Start)
.build();
text_box.append(&desc_label);
} }
// Badge row: category + stars // Badge row: category + stars
@@ -273,7 +279,71 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
card 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("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ");
// 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 { pub fn strip_html(html: &str) -> String {
let mut result = String::with_capacity(html.len()); let mut result = String::with_capacity(html.len());
let mut in_tag = false; let mut in_tag = false;

View File

@@ -5,7 +5,7 @@ use std::rc::Rc;
use gtk::gio; use gtk::gio;
use crate::core::catalog; use crate::core::catalog;
use crate::core::database::{CatalogApp, Database}; use crate::core::database::{CatalogApp, CatalogSortOrder, Database};
use crate::i18n::i18n; use crate::i18n::i18n;
use super::catalog_detail; use super::catalog_detail;
use super::catalog_tile; use super::catalog_tile;
@@ -50,7 +50,6 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
.xalign(0.0) .xalign(0.0)
.halign(gtk::Align::Start) .halign(gtk::Align::Start)
.margin_start(18) .margin_start(18)
.margin_top(6)
.build(); .build();
// Stack for crossfade page transitions // Stack for crossfade page transitions
@@ -148,29 +147,75 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
featured_section.append(&featured_label); featured_section.append(&featured_label);
featured_section.append(&carousel_row); featured_section.append(&carousel_row);
// --- Category filter chips --- // --- Category filter tiles (wrapping grid) ---
let category_box = gtk::FlowBox::builder() let category_box = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.homogeneous(false) .homogeneous(false)
.min_children_per_line(3) .min_children_per_line(3)
.max_children_per_line(20) .max_children_per_line(6)
.row_spacing(6) .selection_mode(gtk::SelectionMode::None)
.column_spacing(6)
.margin_start(18) .margin_start(18)
.margin_end(18) .margin_end(18)
.margin_top(6) .row_spacing(8)
.column_spacing(8)
.build(); .build();
// --- "All Apps" section --- // --- "All Apps" section header with sort dropdown ---
let all_label = gtk::Label::builder() let all_label = gtk::Label::builder()
.label(&i18n("All Apps")) .label(&i18n("All Apps"))
.css_classes(["title-2"]) .css_classes(["title-2"])
.xalign(0.0) .xalign(0.0)
.halign(gtk::Align::Start) .halign(gtk::Align::Start)
.margin_start(18) .hexpand(true)
.margin_top(6)
.build(); .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::<Vec<_>>(),
);
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<std::cell::Cell<CatalogSortOrder>> =
Rc::new(std::cell::Cell::new(CatalogSortOrder::NameAsc));
// FlowBox grid // FlowBox grid
let flow_box = gtk::FlowBox::builder() let flow_box = gtk::FlowBox::builder()
.homogeneous(true) .homogeneous(true)
@@ -191,7 +236,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
let content = gtk::Box::builder() let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
.spacing(8) .spacing(48)
.build(); .build();
// Enrichment banner (hidden by default, shown by background enrichment) // Enrichment banner (hidden by default, shown by background enrichment)
@@ -200,7 +245,6 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
.spacing(8) .spacing(8)
.margin_start(18) .margin_start(18)
.margin_end(18) .margin_end(18)
.margin_top(6)
.visible(false) .visible(false)
.build(); .build();
enrichment_banner.add_css_class("card"); enrichment_banner.add_css_class("card");
@@ -231,8 +275,8 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
content.append(&search_bar); content.append(&search_bar);
content.append(&enrichment_banner); content.append(&enrichment_banner);
content.append(&featured_section); content.append(&featured_section);
content.append(&category_box.clone()); content.append(&category_box);
content.append(&all_label); content.append(&all_header);
content.append(&flow_box); content.append(&flow_box);
clamp.set_child(Some(&content)); clamp.set_child(Some(&content));
@@ -301,7 +345,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
// Populate categories // Populate categories
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, &featured_section, &all_label, nav_view, &toast_overlay,
); );
@@ -310,13 +354,47 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
db, &featured_apps, &featured_page, &featured_stack, &featured_flip, db, &featured_apps, &featured_page, &featured_stack, &featured_flip,
&left_arrow, &right_arrow, nav_view, &toast_overlay, &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 // Search handler
{ {
let db_ref = db.clone(); let db_ref = db.clone();
let flow_ref = flow_box.clone(); let flow_ref = flow_box.clone();
let cat_ref = active_category.clone(); let cat_ref = active_category.clone();
let sort_ref = active_sort.clone();
let nav_ref = nav_view.clone(); let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone(); let toast_ref = toast_overlay.clone();
let all_label_ref = all_label.clone(); let all_label_ref = all_label.clone();
@@ -327,8 +405,8 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
let is_searching = !query.is_empty() || cat.is_some(); let is_searching = !query.is_empty() || cat.is_some();
featured_section_ref.set_visible(!is_searching); featured_section_ref.set_visible(!is_searching);
populate_grid( populate_grid(
&db_ref, &query, cat.as_deref(), &flow_ref, &db_ref, &query, cat.as_deref(), sort_ref.get(),
&all_label_ref, &nav_ref, &toast_ref, &flow_ref, &all_label_ref, &nav_ref, &toast_ref,
); );
}); });
} }
@@ -361,6 +439,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
let title_ref = title.clone(); let title_ref = title.clone();
let cat_box_ref = category_box.clone(); let cat_box_ref = category_box.clone();
let active_cat_ref = active_category.clone(); let active_cat_ref = active_category.clone();
let active_sort_ref = active_sort.clone();
let search_ref = search_entry.clone(); let search_ref = search_entry.clone();
let featured_apps_ref = featured_apps.clone(); let featured_apps_ref = featured_apps.clone();
let featured_page_ref = featured_page.clone(); let featured_page_ref = featured_page.clone();
@@ -383,6 +462,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
let btn_c = btn.clone(); let btn_c = btn.clone();
let cat_box_c = cat_box_ref.clone(); let cat_box_c = cat_box_ref.clone();
let active_cat_c = active_cat_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 search_c = search_ref.clone();
let featured_apps_c = featured_apps_ref.clone(); let featured_apps_c = featured_apps_ref.clone();
let featured_page_c = featured_page_ref.clone(); let featured_page_c = featured_page_ref.clone();
@@ -407,33 +487,69 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
let (tx, rx) = std::sync::mpsc::channel::<catalog::SyncProgress>(); let (tx, rx) = std::sync::mpsc::channel::<catalog::SyncProgress>();
// Listen for progress on the main thread // Listen for progress on the main thread
// Track current source info for progress text
let progress_listen = progress_c.clone(); let progress_listen = progress_c.clone();
glib::timeout_add_local(std::time::Duration::from_millis(50), move || { let current_source_name: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
let current_source_base: Rc<std::cell::Cell<f64>> = Rc::new(std::cell::Cell::new(0.0));
let current_source_span: Rc<std::cell::Cell<f64>> = 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() { while let Ok(progress) = rx.try_recv() {
match progress { match progress {
catalog::SyncProgress::FetchingFeed => { catalog::SyncProgress::SourceStarted { ref name, source_index, source_count } => {
progress_listen.set_fraction(0.0); *src_name.borrow_mut() = name.clone();
progress_listen.set_text(Some("Fetching catalog feed...")); // Divide progress bar evenly across sources
} let span = 1.0 / source_count.max(1) as f64;
catalog::SyncProgress::FeedFetched { total } => { src_base.set(source_index as f64 * span);
progress_listen.set_fraction(0.05); src_span.set(span);
progress_listen.set_text(Some(&format!("Found {} apps, caching icons...", total))); let frac = src_base.get();
}
catalog::SyncProgress::CachingIcon { current, total, .. } => {
let frac = 0.05 + 0.85 * (current as f64 / total.max(1) as f64);
progress_listen.set_fraction(frac); progress_listen.set_fraction(frac);
progress_listen.set_text(Some( 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 } => { catalog::SyncProgress::SavingApps { current, total } => {
let frac = 0.90 + 0.10 * (current as f64 / total.max(1) as f64); let name = src_name.borrow();
progress_listen.set_fraction(frac); 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( progress_listen.set_text(Some(
&format!("Saving apps ({}/{})", current, total), &format!("{}: Saving ({}/{})", &*name, current, total),
)); ));
} }
catalog::SyncProgress::Done { .. } => { 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_fraction(1.0);
progress_listen.set_text(Some("Complete")); progress_listen.set_text(Some("Complete"));
return glib::ControlFlow::Break; return glib::ControlFlow::Break;
@@ -444,7 +560,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
return glib::ControlFlow::Break; return glib::ControlFlow::Break;
} }
glib::ControlFlow::Continue glib::ControlFlow::Continue
}); }});
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || { let result = gio::spawn_blocking(move || {
@@ -452,13 +568,31 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
if let Some(ref db) = db_bg { if let Some(ref db) = db_bg {
catalog::ensure_default_sources(db); catalog::ensure_default_sources(db);
let sources = catalog::get_sources(db); let sources = catalog::get_sources(db);
if let Some(source) = sources.first() { if sources.is_empty() {
catalog::sync_catalog_with_progress(db, source, &move |p| { return Err("No catalog sources configured".to_string());
tx.send(p).ok();
}).map_err(|e| e.to_string())
} else {
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 { } else {
Err("Failed to open database".to_string()) Err("Failed to open database".to_string())
} }
@@ -480,7 +614,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
update_catalog_subtitle(&title_c, count_after); update_catalog_subtitle(&title_c, count_after);
stack_c.set_visible_child_name("results"); stack_c.set_visible_child_name("results");
populate_categories( 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, &featured_section_c, &all_label_c,
&nav_c, &toast_c, &nav_c, &toast_c,
); );
@@ -490,7 +624,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
&left_arrow_c, &right_arrow_c, &nav_c, &toast_c, &left_arrow_c, &right_arrow_c, &nav_c, &toast_c,
); );
populate_grid( 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); let settings = gio::Settings::new(crate::config::APP_ID);
@@ -519,6 +653,8 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
refresh_btn.emit_clicked(); refresh_btn.emit_clicked();
} }
widgets::apply_pointer_cursors(&toolbar_view);
let page = adw::NavigationPage::builder() let page = adw::NavigationPage::builder()
.title(&i18n("Catalog")) .title(&i18n("Catalog"))
.tag("catalog-browse") .tag("catalog-browse")
@@ -679,6 +815,7 @@ fn populate_grid(
db: &Rc<Database>, db: &Rc<Database>,
query: &str, query: &str,
category: Option<&str>, category: Option<&str>,
sort: CatalogSortOrder,
flow_box: &gtk::FlowBox, flow_box: &gtk::FlowBox,
all_label: &gtk::Label, all_label: &gtk::Label,
_nav_view: &adw::NavigationView, _nav_view: &adw::NavigationView,
@@ -689,7 +826,7 @@ fn populate_grid(
flow_box.remove(&child); 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() { if results.is_empty() {
all_label.set_label(&i18n("No results")); 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( fn populate_categories(
db: &Rc<Database>, db: &Rc<Database>,
category_box: &gtk::FlowBox, category_box: &gtk::FlowBox,
active_category: &Rc<RefCell<Option<String>>>, active_category: &Rc<RefCell<Option<String>>>,
active_sort: &Rc<std::cell::Cell<CatalogSortOrder>>,
flow_box: &gtk::FlowBox, flow_box: &gtk::FlowBox,
search_entry: &gtk::SearchEntry, search_entry: &gtk::SearchEntry,
featured_section: &gtk::Box, featured_section: &gtk::Box,
@@ -732,26 +913,23 @@ fn populate_categories(
return; return;
} }
let all_btn = gtk::ToggleButton::builder() let all_btn = build_category_tile(
.label(&i18n("All")) &i18n("All"), "view-grid-symbolic", "cat-accent", true,
.active(true) );
.css_classes(["pill"]) category_box.append(&all_btn);
.build();
category_box.insert(&all_btn, -1);
let buttons: Rc<RefCell<Vec<gtk::ToggleButton>>> = let buttons: Rc<RefCell<Vec<gtk::ToggleButton>>> =
Rc::new(RefCell::new(vec![all_btn.clone()])); Rc::new(RefCell::new(vec![all_btn.clone()]));
for (cat, _count) in categories.iter().take(10) { for (cat, _count) in categories.iter().take(12) {
let btn = gtk::ToggleButton::builder() let (icon_name, color_class) = category_meta(cat);
.label(cat) let btn = build_category_tile(cat, icon_name, color_class, false);
.css_classes(["pill"]) category_box.append(&btn);
.build();
category_box.insert(&btn, -1);
buttons.borrow_mut().push(btn.clone()); buttons.borrow_mut().push(btn.clone());
let cat_str = cat.clone(); let cat_str = cat.clone();
let active_ref = active_category.clone(); let active_ref = active_category.clone();
let sort_ref = active_sort.clone();
let flow_ref = flow_box.clone(); let flow_ref = flow_box.clone();
let search_ref = search_entry.clone(); let search_ref = search_entry.clone();
let db_ref = db.clone(); let db_ref = db.clone();
@@ -771,8 +949,8 @@ fn populate_categories(
featured_section_ref.set_visible(false); featured_section_ref.set_visible(false);
let query = search_ref.text().to_string(); let query = search_ref.text().to_string();
populate_grid( populate_grid(
&db_ref, &query, Some(&cat_str), &flow_ref, &db_ref, &query, Some(&cat_str), sort_ref.get(),
&all_label_ref, &nav_ref, &toast_ref, &flow_ref, &all_label_ref, &nav_ref, &toast_ref,
); );
} }
}); });
@@ -780,6 +958,7 @@ fn populate_categories(
{ {
let active_ref = active_category.clone(); let active_ref = active_category.clone();
let sort_ref = active_sort.clone();
let flow_ref = flow_box.clone(); let flow_ref = flow_box.clone();
let search_ref = search_entry.clone(); let search_ref = search_entry.clone();
let db_ref = db.clone(); let db_ref = db.clone();
@@ -799,8 +978,8 @@ fn populate_categories(
featured_section_ref.set_visible(true); featured_section_ref.set_visible(true);
let query = search_ref.text().to_string(); let query = search_ref.text().to_string();
populate_grid( populate_grid(
&db_ref, &query, None, &flow_ref, &db_ref, &query, None, sort_ref.get(),
&all_label_ref, &nav_ref, &toast_ref, &flow_ref, &all_label_ref, &nav_ref, &toast_ref,
); );
} }
}); });

View File

@@ -69,6 +69,7 @@ pub fn build_dashboard_page(db: &Rc<Database>) -> adw::NavigationPage {
let toolbar = adw::ToolbarView::new(); let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header); toolbar.add_top_bar(&header);
toolbar.set_content(Some(&scrolled)); toolbar.set_content(Some(&scrolled));
widgets::apply_pointer_cursors(&toolbar);
adw::NavigationPage::builder() adw::NavigationPage::builder()
.title("Dashboard") .title("Dashboard")

View File

@@ -197,6 +197,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
let toolbar = adw::ToolbarView::new(); let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header); toolbar.add_top_bar(&header);
toolbar.set_content(Some(&toast_overlay)); toolbar.set_content(Some(&toast_overlay));
widgets::apply_pointer_cursors(&toolbar);
adw::NavigationPage::builder() adw::NavigationPage::builder()
.title(name) .title(name)

View File

@@ -359,6 +359,7 @@ impl LibraryView {
let toolbar_view = adw::ToolbarView::new(); let toolbar_view = adw::ToolbarView::new();
toolbar_view.add_top_bar(&header_bar); toolbar_view.add_top_bar(&header_bar);
toolbar_view.set_content(Some(&content_box)); toolbar_view.set_content(Some(&content_box));
widgets::apply_pointer_cursors(&toolbar_view);
let page = adw::NavigationPage::builder() let page = adw::NavigationPage::builder()
.title("Driftwood") .title("Driftwood")

View File

@@ -14,6 +14,7 @@ pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
dialog.add(&build_general_page(&settings, &dialog)); dialog.add(&build_general_page(&settings, &dialog));
dialog.add(&build_updates_page(&settings)); dialog.add(&build_updates_page(&settings));
super::widgets::apply_pointer_cursors(&dialog);
dialog.present(Some(parent)); dialog.present(Some(parent));
} }

View File

@@ -197,6 +197,7 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
let toolbar_view = adw::ToolbarView::new(); let toolbar_view = adw::ToolbarView::new();
toolbar_view.add_top_bar(&header); toolbar_view.add_top_bar(&header);
toolbar_view.set_content(Some(&toast_overlay)); toolbar_view.set_content(Some(&toast_overlay));
widgets::apply_pointer_cursors(&toolbar_view);
toolbar_view toolbar_view
} }

View File

@@ -55,6 +55,37 @@ fn generate_letter_icon_css() -> String {
css css
} }
/// Set the pointer (hand) cursor on a widget, so it looks clickable on hover.
pub fn set_pointer_cursor(widget: &impl IsA<gtk::Widget>) {
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<gtk::Widget>) {
let w = widget.as_ref();
let is_interactive = w.is::<gtk::Button>()
|| w.is::<gtk::ToggleButton>()
|| w.is::<adw::SplitButton>()
|| w.is::<gtk::Switch>()
|| w.is::<gtk::CheckButton>()
|| w.is::<gtk::DropDown>()
|| w.is::<gtk::Scale>()
|| 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. /// Create a status badge pill label with the given text and style class.
/// Style classes: "success", "warning", "error", "info", "neutral" /// Style classes: "success", "warning", "error", "info", "neutral"
pub fn status_badge(text: &str, style_class: &str) -> gtk::Label { pub fn status_badge(text: &str, style_class: &str) -> gtk::Label {
@@ -439,3 +470,163 @@ pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, 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<u8> = 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: &gtk::Box, markup: &mut String, heading: Option<u8>| {
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 <tt> tag
let escaped = glib::markup_escape_text(&code_block_text);
code_label.set_markup(&format!("<tt>{}</tt>", escaped));
container.append(&code_label);
code_block_text.clear();
}
Event::Start(Tag::Strong) => markup.push_str("<b>"),
Event::End(TagEnd::Strong) => markup.push_str("</b>"),
Event::Start(Tag::Emphasis) => markup.push_str("<i>"),
Event::End(TagEnd::Emphasis) => markup.push_str("</i>"),
Event::Start(Tag::Strikethrough) => markup.push_str("<s>"),
Event::End(TagEnd::Strikethrough) => markup.push_str("</s>"),
Event::Start(Tag::Link { dest_url, .. }) => {
markup.push_str(&format!("<a href=\"{}\">", glib::markup_escape_text(&dest_url)));
}
Event::End(TagEnd::Link) => markup.push_str("</a>"),
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!("<tt>{}</tt>", 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
}