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:
41
Cargo.lock
generated
41
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,9 +216,12 @@ 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())
|
||||||
|
.or(app.github_description.as_deref().filter(|d| !d.is_empty()))
|
||||||
|
.or(app.description.as_deref().filter(|d| !d.is_empty()));
|
||||||
|
if let Some(desc) = feat_desc {
|
||||||
let plain = strip_html(desc);
|
let plain = strip_html(desc);
|
||||||
let snippet: String = plain.chars().take(60).collect();
|
let snippet: String = plain.chars().take(60).collect();
|
||||||
let text = if snippet.len() < plain.len() {
|
let text = if snippet.len() < plain.len() {
|
||||||
@@ -233,7 +240,6 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
|
|||||||
.build();
|
.build();
|
||||||
text_box.append(&desc_label);
|
text_box.append(&desc_label);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Badge row: category + stars
|
// Badge row: category + stars
|
||||||
let badge_row = gtk::Box::builder()
|
let badge_row = gtk::Box::builder()
|
||||||
@@ -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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace(" ", " ");
|
||||||
|
|
||||||
|
// Clean up: trim lines, collapse multiple blank lines
|
||||||
|
let trimmed: Vec<&str> = decoded.lines().map(|l| l.trim()).collect();
|
||||||
|
let mut cleaned = String::new();
|
||||||
|
let mut prev_blank = false;
|
||||||
|
for line in &trimmed {
|
||||||
|
if line.is_empty() {
|
||||||
|
if !prev_blank && !cleaned.is_empty() {
|
||||||
|
cleaned.push('\n');
|
||||||
|
prev_blank = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if prev_blank {
|
||||||
|
cleaned.push('\n');
|
||||||
|
}
|
||||||
|
cleaned.push_str(line);
|
||||||
|
cleaned.push('\n');
|
||||||
|
prev_blank = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip all HTML tags and collapse whitespace. Suitable for short descriptions on tiles.
|
||||||
pub fn strip_html(html: &str) -> String {
|
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;
|
||||||
|
|||||||
@@ -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: >k::FlowBox,
|
flow_box: >k::FlowBox,
|
||||||
all_label: >k::Label,
|
all_label: >k::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: >k::FlowBox,
|
category_box: >k::FlowBox,
|
||||||
active_category: &Rc<RefCell<Option<String>>>,
|
active_category: &Rc<RefCell<Option<String>>>,
|
||||||
|
active_sort: &Rc<std::cell::Cell<CatalogSortOrder>>,
|
||||||
flow_box: >k::FlowBox,
|
flow_box: >k::FlowBox,
|
||||||
search_entry: >k::SearchEntry,
|
search_entry: >k::SearchEntry,
|
||||||
featured_section: >k::Box,
|
featured_section: >k::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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: >k::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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user