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",
|
||||
"notify",
|
||||
"notify-rust",
|
||||
"pulldown-cmark",
|
||||
"quick-xml",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
@@ -876,6 +877,15 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1804,6 +1814,25 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"getopts",
|
||||
"memchr",
|
||||
"pulldown-cmark-escape",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulldown-cmark-escape"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
@@ -2335,12 +2364,24 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
|
||||
@@ -51,5 +51,8 @@ notify-rust = "4"
|
||||
# File system watching (inotify)
|
||||
notify = "7"
|
||||
|
||||
# Markdown parsing (for GitHub README rendering)
|
||||
pulldown-cmark = "0.12"
|
||||
|
||||
[build-dependencies]
|
||||
glib-build-tools = "0.22"
|
||||
|
||||
@@ -175,6 +175,58 @@ button.flat:not(.pill):not(.suggested-action):not(.destructive-action) {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
/* ===== Category Filter Tiles ===== */
|
||||
.category-tile {
|
||||
padding: 14px 18px;
|
||||
min-height: 48px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-tile image {
|
||||
color: white;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Colored backgrounds per category */
|
||||
.cat-accent { background: alpha(@accent_bg_color, 0.7); }
|
||||
.cat-purple { background: alpha(@purple_3, 0.65); }
|
||||
.cat-red { background: alpha(@red_3, 0.6); }
|
||||
.cat-green { background: alpha(@success_bg_color, 0.55); }
|
||||
.cat-orange { background: alpha(@orange_3, 0.65); }
|
||||
.cat-blue { background: alpha(@blue_3, 0.6); }
|
||||
.cat-amber { background: alpha(@warning_bg_color, 0.6); }
|
||||
.cat-neutral { background: alpha(@window_fg_color, 0.2); }
|
||||
|
||||
/* Hover: intensify the background */
|
||||
.cat-accent:hover { background: alpha(@accent_bg_color, 0.85); }
|
||||
.cat-purple:hover { background: alpha(@purple_3, 0.8); }
|
||||
.cat-red:hover { background: alpha(@red_3, 0.75); }
|
||||
.cat-green:hover { background: alpha(@success_bg_color, 0.7); }
|
||||
.cat-orange:hover { background: alpha(@orange_3, 0.8); }
|
||||
.cat-blue:hover { background: alpha(@blue_3, 0.75); }
|
||||
.cat-amber:hover { background: alpha(@warning_bg_color, 0.75); }
|
||||
.cat-neutral:hover { background: alpha(@window_fg_color, 0.3); }
|
||||
|
||||
/* Checked: full-strength background + light border for emphasis */
|
||||
.cat-accent:checked { background: @accent_bg_color; }
|
||||
.cat-purple:checked { background: @purple_3; }
|
||||
.cat-red:checked { background: @red_3; }
|
||||
.cat-green:checked { background: @success_bg_color; }
|
||||
.cat-orange:checked { background: @orange_3; }
|
||||
.cat-blue:checked { background: @blue_3; }
|
||||
.cat-amber:checked { background: @warning_bg_color; }
|
||||
.cat-neutral:checked { background: alpha(@window_fg_color, 0.45); }
|
||||
|
||||
/* Focus indicator on the tile itself */
|
||||
flowboxchild:focus-visible .category-tile {
|
||||
outline: 2px solid @accent_bg_color;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ===== Catalog Tile Cards ===== */
|
||||
.catalog-tile {
|
||||
border: 1px solid alpha(@window_fg_color, 0.12);
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct CatalogSource {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CatalogType {
|
||||
AppImageHub,
|
||||
OcsAppImageHub,
|
||||
GitHubSearch,
|
||||
Custom,
|
||||
}
|
||||
@@ -28,6 +29,7 @@ impl CatalogType {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::AppImageHub => "appimage-hub",
|
||||
Self::OcsAppImageHub => "ocs-appimagehub",
|
||||
Self::GitHubSearch => "github-search",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
@@ -36,6 +38,7 @@ impl CatalogType {
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"appimage-hub" => Self::AppImageHub,
|
||||
"ocs-appimagehub" => Self::OcsAppImageHub,
|
||||
"github-search" => Self::GitHubSearch,
|
||||
_ => Self::Custom,
|
||||
}
|
||||
@@ -63,10 +66,138 @@ pub struct CatalogApp {
|
||||
/// Default AppImageHub registry URL.
|
||||
const APPIMAGEHUB_API_URL: &str = "https://appimage.github.io/feed.json";
|
||||
|
||||
/// OCS API base URL for AppImageHub.com.
|
||||
const OCS_API_URL: &str = "https://api.appimagehub.com/ocs/v1/content/data";
|
||||
const OCS_PAGE_SIZE: u32 = 100;
|
||||
|
||||
// --- OCS API response types ---
|
||||
|
||||
/// Deserialize a JSON value that may be a number, a numeric string, or an empty string.
|
||||
/// The OCS API is loosely typed and sometimes returns "" instead of null for numeric fields.
|
||||
fn deserialize_lenient_i64<'de, D>(deserializer: D) -> Result<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.
|
||||
/// Progress updates sent during catalog sync.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncProgress {
|
||||
/// Starting sync for a named source.
|
||||
SourceStarted { name: String, source_index: u32, source_count: u32 },
|
||||
/// Fetching the feed from the remote source.
|
||||
FetchingFeed,
|
||||
/// Feed fetched, total number of apps found.
|
||||
@@ -75,8 +206,10 @@ pub enum SyncProgress {
|
||||
CachingIcon { current: u32, total: u32, app_name: String },
|
||||
/// Saving apps to the database.
|
||||
SavingApps { current: u32, total: u32 },
|
||||
/// Sync complete.
|
||||
/// Single source sync complete.
|
||||
Done { total: u32 },
|
||||
/// All sources finished syncing.
|
||||
AllDone,
|
||||
}
|
||||
|
||||
pub fn sync_catalog(db: &Database, source: &CatalogSource) -> Result<u32, CatalogError> {
|
||||
@@ -90,6 +223,13 @@ pub fn sync_catalog_with_progress(
|
||||
) -> Result<u32, CatalogError> {
|
||||
on_progress(SyncProgress::FetchingFeed);
|
||||
|
||||
match source.source_type {
|
||||
CatalogType::OcsAppImageHub => {
|
||||
return sync_ocs_catalog(db, source, on_progress);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let apps = match source.source_type {
|
||||
CatalogType::AppImageHub => fetch_appimage_hub()?,
|
||||
CatalogType::Custom => fetch_custom_catalog(&source.url)?,
|
||||
@@ -97,6 +237,7 @@ pub fn sync_catalog_with_progress(
|
||||
log::warn!("GitHub catalog search not yet implemented");
|
||||
Vec::new()
|
||||
}
|
||||
CatalogType::OcsAppImageHub => unreachable!(),
|
||||
};
|
||||
|
||||
let total = apps.len() as u32;
|
||||
@@ -107,9 +248,18 @@ pub fn sync_catalog_with_progress(
|
||||
log::info!("Cached {} catalog icons", icon_count);
|
||||
|
||||
let source_id = source.id.ok_or(CatalogError::NoSourceId)?;
|
||||
|
||||
// Build set of OCS app names for dedup (skip apps already in OCS source)
|
||||
let ocs_names = get_ocs_source_names(db);
|
||||
|
||||
let mut count = 0u32;
|
||||
|
||||
for app in &apps {
|
||||
// Deduplicate: skip if this app name exists in the OCS source
|
||||
if ocs_names.contains(&app.name.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
on_progress(SyncProgress::SavingApps { current: count, total });
|
||||
|
||||
@@ -153,20 +303,54 @@ pub fn sync_catalog_with_progress(
|
||||
}
|
||||
|
||||
/// Download an AppImage from the catalog to a local directory.
|
||||
/// If `ocs_id` is provided, resolves a fresh download URL from the OCS API
|
||||
/// (since OCS download links use JWT tokens that expire).
|
||||
pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result<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()))?;
|
||||
|
||||
// Derive filename from URL
|
||||
let filename = app.download_url
|
||||
// For OCS apps, resolve a fresh download URL (JWT links expire)
|
||||
let download_url = if let Some(id) = ocs_id {
|
||||
match resolve_ocs_download(id, ocs_slot) {
|
||||
Ok(url) => url,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to resolve OCS download, falling back to stored URL: {}", e);
|
||||
app.download_url.clone()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.download_url.clone()
|
||||
};
|
||||
|
||||
// Derive filename from URL (strip query params)
|
||||
let url_path = download_url.split('?').next().unwrap_or(&download_url);
|
||||
let filename = url_path
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.unwrap_or("downloaded.AppImage");
|
||||
|
||||
let dest = install_dir.join(filename);
|
||||
// If filename doesn't look like an AppImage, use the app name
|
||||
let filename = if filename.contains(".AppImage") || filename.ends_with(".appimage") {
|
||||
filename.to_string()
|
||||
} else {
|
||||
format!("{}.AppImage", sanitize_filename(&app.name))
|
||||
};
|
||||
|
||||
log::info!("Downloading {} to {}", app.download_url, dest.display());
|
||||
let dest = install_dir.join(&filename);
|
||||
|
||||
let response = ureq::get(&app.download_url)
|
||||
log::info!("Downloading {} to {}", download_url, dest.display());
|
||||
|
||||
let response = ureq::get(&download_url)
|
||||
.call()
|
||||
.map_err(|e| CatalogError::Network(e.to_string()))?;
|
||||
|
||||
@@ -195,6 +379,419 @@ pub fn install_from_catalog(app: &CatalogApp, install_dir: &Path) -> Result<Path
|
||||
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.
|
||||
fn fetch_appimage_hub() -> Result<Vec<CatalogApp>, CatalogError> {
|
||||
let response = ureq::get(APPIMAGEHUB_API_URL)
|
||||
@@ -273,8 +870,18 @@ fn fetch_custom_catalog(url: &str) -> Result<Vec<CatalogApp>, CatalogError> {
|
||||
}).collect())
|
||||
}
|
||||
|
||||
/// Ensure the default AppImageHub source exists in the database.
|
||||
/// Ensure the default catalog sources exist in the database.
|
||||
/// OCS AppImageHub.com is the primary source (richer metadata), and
|
||||
/// appimage.github.io is the secondary source for apps not in OCS.
|
||||
pub fn ensure_default_sources(db: &Database) {
|
||||
// Primary: OCS AppImageHub.com (insert first so it syncs first)
|
||||
db.upsert_catalog_source(
|
||||
"AppImageHub.com",
|
||||
OCS_API_URL,
|
||||
"ocs-appimagehub",
|
||||
).ok();
|
||||
|
||||
// Secondary: appimage.github.io feed
|
||||
db.upsert_catalog_source(
|
||||
"AppImageHub",
|
||||
APPIMAGEHUB_API_URL,
|
||||
@@ -319,6 +926,45 @@ pub fn screenshot_cache_dir() -> PathBuf {
|
||||
dir
|
||||
}
|
||||
|
||||
/// Shrink an OCS CDN image URL to a smaller cache size.
|
||||
/// OCS URLs look like https://images.pling.com/cache/770x540-4/img/...
|
||||
/// We can replace the size portion to get smaller images.
|
||||
fn shrink_ocs_image_url(url: &str, size: &str) -> String {
|
||||
if let Some(start) = url.find("/cache/") {
|
||||
let after_cache = start + "/cache/".len();
|
||||
if let Some(end) = url[after_cache..].find('/') {
|
||||
let mut result = String::with_capacity(url.len());
|
||||
result.push_str(&url[..after_cache]);
|
||||
result.push_str(size);
|
||||
result.push_str(&url[after_cache + end..]);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
/// Clear the screenshot cache directory to force re-download of screenshots.
|
||||
/// Called during catalog sync to avoid stale cached images.
|
||||
fn clear_screenshot_cache() {
|
||||
let cache_dir = screenshot_cache_dir();
|
||||
if cache_dir.exists() {
|
||||
fs::remove_dir_all(&cache_dir).ok();
|
||||
fs::create_dir_all(&cache_dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear icon cache entries for OCS apps to force re-download with updated URLs.
|
||||
fn clear_ocs_icon_cache(apps: &[CatalogApp]) {
|
||||
let cache_dir = icon_cache_dir();
|
||||
for app in apps {
|
||||
let sanitized = sanitize_filename(&app.name);
|
||||
let path = cache_dir.join(format!("{}.png", sanitized));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an asset path to a full URL (handles relative paths from AppImageHub).
|
||||
fn resolve_asset_url(path: &str) -> String {
|
||||
if path.starts_with("http://") || path.starts_with("https://") {
|
||||
@@ -492,6 +1138,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_catalog_type_roundtrip() {
|
||||
assert_eq!(CatalogType::from_str("appimage-hub"), CatalogType::AppImageHub);
|
||||
assert_eq!(CatalogType::from_str("ocs-appimagehub"), CatalogType::OcsAppImageHub);
|
||||
assert_eq!(CatalogType::from_str("github-search"), CatalogType::GitHubSearch);
|
||||
assert_eq!(CatalogType::from_str("custom"), CatalogType::Custom);
|
||||
assert_eq!(CatalogType::from_str("unknown"), CatalogType::Custom);
|
||||
@@ -500,6 +1147,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_catalog_type_as_str() {
|
||||
assert_eq!(CatalogType::AppImageHub.as_str(), "appimage-hub");
|
||||
assert_eq!(CatalogType::OcsAppImageHub.as_str(), "ocs-appimagehub");
|
||||
assert_eq!(CatalogType::GitHubSearch.as_str(), "github-search");
|
||||
assert_eq!(CatalogType::Custom.as_str(), "custom");
|
||||
}
|
||||
@@ -517,9 +1165,12 @@ mod tests {
|
||||
let db = crate::core::database::Database::open_in_memory().unwrap();
|
||||
ensure_default_sources(&db);
|
||||
let sources = get_sources(&db);
|
||||
assert_eq!(sources.len(), 1);
|
||||
assert_eq!(sources[0].name, "AppImageHub");
|
||||
assert_eq!(sources[0].source_type, CatalogType::AppImageHub);
|
||||
assert_eq!(sources.len(), 2);
|
||||
let ocs = sources.iter().find(|s| s.source_type == CatalogType::OcsAppImageHub);
|
||||
assert!(ocs.is_some(), "OCS source should exist");
|
||||
assert_eq!(ocs.unwrap().name, "AppImageHub.com");
|
||||
let hub = sources.iter().find(|s| s.source_type == CatalogType::AppImageHub);
|
||||
assert!(hub.is_some(), "AppImageHub source should exist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -88,6 +88,33 @@ pub struct SystemModification {
|
||||
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)]
|
||||
pub struct CatalogApp {
|
||||
pub id: i64,
|
||||
@@ -108,6 +135,27 @@ pub struct CatalogApp {
|
||||
pub github_enriched_at: Option<String>,
|
||||
pub github_download_url: 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)]
|
||||
@@ -426,6 +474,18 @@ impl Database {
|
||||
self.migrate_to_v15()?;
|
||||
}
|
||||
|
||||
if current_version < 16 {
|
||||
self.migrate_to_v16()?;
|
||||
}
|
||||
|
||||
if current_version < 17 {
|
||||
self.migrate_to_v17()?;
|
||||
}
|
||||
|
||||
if current_version < 18 {
|
||||
self.migrate_to_v18()?;
|
||||
}
|
||||
|
||||
// Ensure all expected columns exist (repairs DBs where a migration
|
||||
// was updated after it had already run on this database)
|
||||
self.ensure_columns()?;
|
||||
@@ -930,6 +990,68 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_to_v16(&self) -> SqlResult<()> {
|
||||
let new_columns = [
|
||||
"github_description TEXT",
|
||||
"github_readme TEXT",
|
||||
];
|
||||
for col in &new_columns {
|
||||
let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col);
|
||||
self.conn.execute(&sql, []).ok();
|
||||
}
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![16],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_to_v17(&self) -> SqlResult<()> {
|
||||
let new_columns = [
|
||||
"ocs_id INTEGER",
|
||||
"ocs_downloads INTEGER",
|
||||
"ocs_score INTEGER",
|
||||
"ocs_typename TEXT",
|
||||
"ocs_personid TEXT",
|
||||
"ocs_description TEXT",
|
||||
"ocs_summary TEXT",
|
||||
"ocs_version TEXT",
|
||||
"ocs_tags TEXT",
|
||||
"ocs_changed TEXT",
|
||||
"ocs_preview_url TEXT",
|
||||
];
|
||||
for col in &new_columns {
|
||||
let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col);
|
||||
self.conn.execute(&sql, []).ok();
|
||||
}
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![17],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_to_v18(&self) -> SqlResult<()> {
|
||||
let new_columns = [
|
||||
"ocs_detailpage TEXT",
|
||||
"ocs_created TEXT",
|
||||
"ocs_downloadname TEXT",
|
||||
"ocs_downloadsize INTEGER",
|
||||
"ocs_arch TEXT",
|
||||
"ocs_md5sum TEXT",
|
||||
"ocs_comments INTEGER",
|
||||
];
|
||||
for col in &new_columns {
|
||||
let sql = format!("ALTER TABLE catalog_apps ADD COLUMN {}", col);
|
||||
self.conn.execute(&sql, []).ok();
|
||||
}
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![18],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_appimage(
|
||||
&self,
|
||||
path: &str,
|
||||
@@ -1095,6 +1217,57 @@ impl Database {
|
||||
previous_version_path, source_url, autostart, startup_wm_class,
|
||||
verification_status, first_run_prompted, system_wide, is_portable, mount_point";
|
||||
|
||||
const CATALOG_APP_COLUMNS: &str =
|
||||
"id, name, description, categories, download_url, icon_url, homepage, license, screenshots,
|
||||
github_owner, github_repo, github_stars, github_downloads, latest_version, release_date,
|
||||
github_enriched_at, github_download_url, github_release_assets, github_description, github_readme,
|
||||
ocs_id, ocs_downloads, ocs_score, ocs_typename, ocs_personid, ocs_description, ocs_summary,
|
||||
ocs_version, ocs_tags, ocs_changed, ocs_preview_url,
|
||||
ocs_detailpage, ocs_created, ocs_downloadname, ocs_downloadsize, ocs_arch, ocs_md5sum, ocs_comments";
|
||||
|
||||
fn catalog_app_from_row(row: &rusqlite::Row) -> rusqlite::Result<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> {
|
||||
Ok(AppImageRecord {
|
||||
id: row.get(0)?,
|
||||
@@ -2159,16 +2332,16 @@ impl Database {
|
||||
query: &str,
|
||||
category: Option<&str>,
|
||||
limit: i32,
|
||||
sort: CatalogSortOrder,
|
||||
) -> SqlResult<Vec<CatalogApp>> {
|
||||
let mut sql = String::from(
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots,
|
||||
github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets
|
||||
FROM catalog_apps WHERE 1=1"
|
||||
let mut sql = format!(
|
||||
"SELECT {} FROM catalog_apps WHERE 1=1",
|
||||
Self::CATALOG_APP_COLUMNS
|
||||
);
|
||||
let mut params_list: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
|
||||
|
||||
if !query.is_empty() {
|
||||
sql.push_str(" AND (name LIKE ?1 OR description LIKE ?1)");
|
||||
sql.push_str(" AND (name LIKE ?1 OR description LIKE ?1 OR ocs_summary LIKE ?1 OR ocs_description LIKE ?1)");
|
||||
params_list.push(Box::new(format!("%{}%", query)));
|
||||
}
|
||||
|
||||
@@ -2178,34 +2351,13 @@ impl Database {
|
||||
params_list.push(Box::new(format!("%{}%", cat)));
|
||||
}
|
||||
|
||||
sql.push_str(&format!(" ORDER BY name LIMIT {}", limit));
|
||||
sql.push_str(&format!(" {} LIMIT {}", sort.sql_clause(), limit));
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
params_list.iter().map(|p| p.as_ref()).collect();
|
||||
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), |row| {
|
||||
Ok(CatalogApp {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
categories: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
license: row.get(7)?,
|
||||
screenshots: row.get(8)?,
|
||||
github_owner: row.get(9)?,
|
||||
github_repo: row.get(10)?,
|
||||
github_stars: row.get(11)?,
|
||||
github_downloads: row.get(12)?,
|
||||
latest_version: row.get(13)?,
|
||||
release_date: row.get(14)?,
|
||||
github_enriched_at: row.get(15)?,
|
||||
github_download_url: row.get(16)?,
|
||||
github_release_assets: row.get(17)?,
|
||||
})
|
||||
})?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), Self::catalog_app_from_row)?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
@@ -2215,34 +2367,11 @@ impl Database {
|
||||
}
|
||||
|
||||
pub fn get_catalog_app(&self, id: i64) -> SqlResult<Option<CatalogApp>> {
|
||||
let result = self.conn.query_row(
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots,
|
||||
github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets
|
||||
FROM catalog_apps WHERE id = ?1",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(CatalogApp {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
categories: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
license: row.get(7)?,
|
||||
screenshots: row.get(8)?,
|
||||
github_owner: row.get(9)?,
|
||||
github_repo: row.get(10)?,
|
||||
github_stars: row.get(11)?,
|
||||
github_downloads: row.get(12)?,
|
||||
latest_version: row.get(13)?,
|
||||
release_date: row.get(14)?,
|
||||
github_enriched_at: row.get(15)?,
|
||||
github_download_url: row.get(16)?,
|
||||
github_release_assets: row.get(17)?,
|
||||
})
|
||||
},
|
||||
let sql = format!(
|
||||
"SELECT {} FROM catalog_apps WHERE id = ?1",
|
||||
Self::CATALOG_APP_COLUMNS
|
||||
);
|
||||
let result = self.conn.query_row(&sql, params![id], Self::catalog_app_from_row);
|
||||
match result {
|
||||
Ok(app) => Ok(Some(app)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
@@ -2250,8 +2379,9 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get featured catalog apps. Apps with GitHub stars sort first (by stars desc),
|
||||
/// then unenriched apps get a deterministic shuffle that rotates every 15 minutes.
|
||||
/// Get featured catalog apps. Apps with enrichment data (stars or OCS downloads)
|
||||
/// sort first by combined popularity, then unenriched apps get a deterministic
|
||||
/// shuffle that rotates every 15 minutes.
|
||||
pub fn get_featured_catalog_apps(&self, limit: i32) -> SqlResult<Vec<CatalogApp>> {
|
||||
// Time seed rotates every 15 minutes (900 seconds)
|
||||
let time_seed = std::time::SystemTime::now()
|
||||
@@ -2259,46 +2389,31 @@ impl Database {
|
||||
.unwrap_or_default()
|
||||
.as_secs() / 900;
|
||||
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots,
|
||||
github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets
|
||||
FROM catalog_apps
|
||||
let sql = format!(
|
||||
"SELECT {} FROM catalog_apps
|
||||
WHERE icon_url IS NOT NULL AND icon_url != ''
|
||||
AND description IS NOT NULL AND description != ''
|
||||
AND screenshots IS NOT NULL AND screenshots != ''"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(CatalogApp {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
categories: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
license: row.get(7)?,
|
||||
screenshots: row.get(8)?,
|
||||
github_owner: row.get(9)?,
|
||||
github_repo: row.get(10)?,
|
||||
github_stars: row.get(11)?,
|
||||
github_downloads: row.get(12)?,
|
||||
latest_version: row.get(13)?,
|
||||
release_date: row.get(14)?,
|
||||
github_enriched_at: row.get(15)?,
|
||||
github_download_url: row.get(16)?,
|
||||
github_release_assets: row.get(17)?,
|
||||
})
|
||||
})?;
|
||||
AND (description IS NOT NULL AND description != ''
|
||||
OR ocs_summary IS NOT NULL AND ocs_summary != '')
|
||||
AND (screenshots IS NOT NULL AND screenshots != ''
|
||||
OR ocs_preview_url IS NOT NULL AND ocs_preview_url != '')",
|
||||
Self::CATALOG_APP_COLUMNS
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], Self::catalog_app_from_row)?;
|
||||
let mut apps: Vec<CatalogApp> = rows.collect::<SqlResult<Vec<_>>>()?;
|
||||
|
||||
// Enriched apps (with stars) sort first by stars descending,
|
||||
// unenriched apps get the deterministic shuffle after them
|
||||
// Sort by combined popularity: OCS downloads + GitHub stars.
|
||||
// Apps with any enrichment sort first, then deterministic shuffle.
|
||||
apps.sort_by(|a, b| {
|
||||
match (a.github_stars, b.github_stars) {
|
||||
(Some(sa), Some(sb)) => sb.cmp(&sa),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => {
|
||||
let a_pop = a.ocs_downloads.unwrap_or(0) + a.github_stars.unwrap_or(0);
|
||||
let b_pop = b.ocs_downloads.unwrap_or(0) + b.github_stars.unwrap_or(0);
|
||||
let a_enriched = a_pop > 0;
|
||||
let b_enriched = b_pop > 0;
|
||||
match (a_enriched, b_enriched) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
(true, true) => b_pop.cmp(&a_pop),
|
||||
(false, false) => {
|
||||
let ha = (a.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
|
||||
let hb = (b.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
|
||||
ha.cmp(&hb)
|
||||
@@ -2369,6 +2484,97 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert_ocs_catalog_app(
|
||||
&self,
|
||||
source_id: i64,
|
||||
name: &str,
|
||||
ocs_id: i64,
|
||||
description: Option<&str>,
|
||||
summary: Option<&str>,
|
||||
categories: Option<&str>,
|
||||
version: Option<&str>,
|
||||
download_url: &str,
|
||||
icon_url: Option<&str>,
|
||||
homepage: Option<&str>,
|
||||
screenshots: Option<&str>,
|
||||
ocs_downloads: Option<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>> {
|
||||
let pattern = format!("%{}%", query);
|
||||
let mut stmt = self.conn.prepare(
|
||||
@@ -2453,10 +2659,11 @@ impl Database {
|
||||
app_id: i64,
|
||||
stars: i64,
|
||||
pushed_at: Option<&str>,
|
||||
description: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET github_stars = ?2, github_enriched_at = datetime('now') WHERE id = ?1",
|
||||
params![app_id, stars],
|
||||
"UPDATE catalog_apps SET github_stars = ?2, github_description = COALESCE(?3, github_description), github_enriched_at = datetime('now') WHERE id = ?1",
|
||||
params![app_id, stars, description],
|
||||
)?;
|
||||
// Store pushed_at in release_date if no release info yet
|
||||
if let Some(pushed) = pushed_at {
|
||||
@@ -2492,36 +2699,15 @@ impl Database {
|
||||
}
|
||||
|
||||
pub fn get_unenriched_catalog_apps(&self, limit: i32) -> SqlResult<Vec<CatalogApp>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, license, screenshots,
|
||||
github_owner, github_repo, github_stars, github_downloads, latest_version, release_date, github_enriched_at, github_download_url, github_release_assets
|
||||
FROM catalog_apps
|
||||
let sql = format!(
|
||||
"SELECT {} FROM catalog_apps
|
||||
WHERE github_owner IS NOT NULL AND github_enriched_at IS NULL
|
||||
ORDER BY id
|
||||
LIMIT ?1"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit], |row| {
|
||||
Ok(CatalogApp {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
categories: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
license: row.get(7)?,
|
||||
screenshots: row.get(8)?,
|
||||
github_owner: row.get(9)?,
|
||||
github_repo: row.get(10)?,
|
||||
github_stars: row.get(11)?,
|
||||
github_downloads: row.get(12)?,
|
||||
latest_version: row.get(13)?,
|
||||
release_date: row.get(14)?,
|
||||
github_enriched_at: row.get(15)?,
|
||||
github_download_url: row.get(16)?,
|
||||
github_release_assets: row.get(17)?,
|
||||
})
|
||||
})?;
|
||||
LIMIT ?1",
|
||||
Self::CATALOG_APP_COLUMNS
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![limit], Self::catalog_app_from_row)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
@@ -2538,6 +2724,18 @@ impl Database {
|
||||
)?;
|
||||
Ok((enriched, total_with_github))
|
||||
}
|
||||
|
||||
pub fn update_catalog_app_readme(
|
||||
&self,
|
||||
app_id: i64,
|
||||
readme: &str,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET github_readme = ?2 WHERE id = ?1",
|
||||
params![app_id, readme],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2708,7 +2906,7 @@ mod tests {
|
||||
[],
|
||||
|row| row.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(version, 15);
|
||||
assert_eq!(version, 18);
|
||||
|
||||
// All tables that should exist after the full v1-v7 migration chain
|
||||
let expected_tables = [
|
||||
|
||||
@@ -110,6 +110,54 @@ pub fn fetch_release_info(owner: &str, repo: &str, token: &str) -> Result<(GitHu
|
||||
Ok((info, remaining))
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct GitHubReadmeResponse {
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
encoding: String,
|
||||
}
|
||||
|
||||
/// Fetch the README content for a repo (decoded from base64).
|
||||
pub fn fetch_readme(owner: &str, repo: &str, token: &str) -> Result<(String, u32), String> {
|
||||
let url = format!("https://api.github.com/repos/{}/{}/readme", owner, repo);
|
||||
let (body, remaining) = github_get(&url, token)?;
|
||||
let resp: GitHubReadmeResponse = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Parse error: {}", e))?;
|
||||
|
||||
if resp.encoding != "base64" {
|
||||
return Err(format!("Unexpected encoding: {}", resp.encoding));
|
||||
}
|
||||
|
||||
// GitHub returns base64 with newlines; strip them before decoding
|
||||
let clean = resp.content.replace('\n', "");
|
||||
let decoded = base64_decode(&clean)
|
||||
.map_err(|e| format!("Base64 decode error: {}", e))?;
|
||||
let text = String::from_utf8(decoded)
|
||||
.map_err(|e| format!("UTF-8 error: {}", e))?;
|
||||
Ok((text, remaining))
|
||||
}
|
||||
|
||||
/// Simple base64 decoder (standard alphabet, no padding required).
|
||||
fn base64_decode(input: &str) -> Result<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 ---
|
||||
|
||||
/// A simplified release asset for storage (JSON-serializable).
|
||||
@@ -163,7 +211,7 @@ pub fn pick_best_asset(assets: &[AppImageAsset]) -> Option<&AppImageAsset> {
|
||||
|
||||
// --- Enrichment logic ---
|
||||
|
||||
/// Enrich a catalog app with repo-level info (stars, pushed_at).
|
||||
/// Enrich a catalog app with repo-level info (stars, pushed_at, description).
|
||||
pub fn enrich_app_repo_info(
|
||||
db: &Database,
|
||||
app_id: i64,
|
||||
@@ -172,8 +220,9 @@ pub fn enrich_app_repo_info(
|
||||
token: &str,
|
||||
) -> Result<u32, String> {
|
||||
let (info, remaining) = fetch_repo_info(owner, repo, token)?;
|
||||
db.update_catalog_app_github_metadata(app_id, info.stargazers_count, info.pushed_at.as_deref())
|
||||
.map_err(|e| format!("DB error: {}", e))?;
|
||||
db.update_catalog_app_github_metadata(
|
||||
app_id, info.stargazers_count, info.pushed_at.as_deref(), info.description.as_deref(),
|
||||
).map_err(|e| format!("DB error: {}", e))?;
|
||||
Ok(remaining)
|
||||
}
|
||||
|
||||
@@ -216,6 +265,20 @@ pub fn enrich_app_release_info(
|
||||
Ok(remaining)
|
||||
}
|
||||
|
||||
/// Fetch and store the README for a catalog app.
|
||||
pub fn enrich_app_readme(
|
||||
db: &Database,
|
||||
app_id: i64,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
token: &str,
|
||||
) -> Result<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.
|
||||
/// Returns (count_enriched, should_continue).
|
||||
pub fn background_enrich_batch(
|
||||
@@ -261,7 +324,7 @@ pub fn background_enrich_batch(
|
||||
Err(e) => {
|
||||
log::warn!("Failed to enrich {}/{}: {}", owner, repo, e);
|
||||
// Mark as enriched anyway so we don't retry forever
|
||||
db.update_catalog_app_github_metadata(app.id, 0, None).ok();
|
||||
db.update_catalog_app_github_metadata(app.id, 0, None, None).ok();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
|
||||
.child(&card)
|
||||
.build();
|
||||
child.add_css_class("activatable");
|
||||
super::widgets::set_pointer_cursor(&child);
|
||||
|
||||
// Accessible label for screen readers
|
||||
let accessible_name = build_accessible_label(record);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,9 +45,11 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
|
||||
.build();
|
||||
inner.append(&name_label);
|
||||
|
||||
// Description (always 2 lines for uniform height)
|
||||
let plain = app.description.as_deref()
|
||||
// Description (always 2 lines for uniform height) - prefer OCS summary, then GitHub description
|
||||
let plain = app.ocs_summary.as_deref()
|
||||
.filter(|d| !d.is_empty())
|
||||
.or(app.github_description.as_deref().filter(|d| !d.is_empty()))
|
||||
.or(app.description.as_deref().filter(|d| !d.is_empty()))
|
||||
.map(|d| strip_html(d))
|
||||
.unwrap_or_default();
|
||||
let snippet: String = plain.chars().take(80).collect();
|
||||
@@ -139,6 +141,7 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
|
||||
.child(&card)
|
||||
.build();
|
||||
child.add_css_class("activatable");
|
||||
widgets::set_pointer_cursor(&child);
|
||||
|
||||
child
|
||||
}
|
||||
@@ -158,6 +161,7 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
|
||||
card.add_css_class("card");
|
||||
card.add_css_class("catalog-featured-card");
|
||||
card.add_css_class("activatable");
|
||||
widgets::set_pointer_cursor(&card);
|
||||
card.set_widget_name(&format!("featured-{}", app.id));
|
||||
|
||||
// Screenshot preview area (top)
|
||||
@@ -212,27 +216,29 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
|
||||
.build();
|
||||
text_box.append(&name_label);
|
||||
|
||||
// Description (1 line in featured since space is tight)
|
||||
if let Some(ref desc) = app.description {
|
||||
if !desc.is_empty() {
|
||||
let plain = strip_html(desc);
|
||||
let snippet: String = plain.chars().take(60).collect();
|
||||
let text = if snippet.len() < plain.len() {
|
||||
format!("{}...", snippet.trim_end())
|
||||
} else {
|
||||
snippet
|
||||
};
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label(&text)
|
||||
.css_classes(["caption", "dim-label"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.lines(1)
|
||||
.xalign(0.0)
|
||||
.max_width_chars(35)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
text_box.append(&desc_label);
|
||||
}
|
||||
// Description (1 line in featured since space is tight) - prefer OCS summary
|
||||
let feat_desc = app.ocs_summary.as_deref()
|
||||
.filter(|d| !d.is_empty())
|
||||
.or(app.github_description.as_deref().filter(|d| !d.is_empty()))
|
||||
.or(app.description.as_deref().filter(|d| !d.is_empty()));
|
||||
if let Some(desc) = feat_desc {
|
||||
let plain = strip_html(desc);
|
||||
let snippet: String = plain.chars().take(60).collect();
|
||||
let text = if snippet.len() < plain.len() {
|
||||
format!("{}...", snippet.trim_end())
|
||||
} else {
|
||||
snippet
|
||||
};
|
||||
let desc_label = gtk::Label::builder()
|
||||
.label(&text)
|
||||
.css_classes(["caption", "dim-label"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.lines(1)
|
||||
.xalign(0.0)
|
||||
.max_width_chars(35)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
text_box.append(&desc_label);
|
||||
}
|
||||
|
||||
// Badge row: category + stars
|
||||
@@ -273,7 +279,71 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
|
||||
card
|
||||
}
|
||||
|
||||
/// Strip HTML tags from a string, returning plain text.
|
||||
/// Convert HTML to readable formatted plain text, preserving paragraph breaks,
|
||||
/// line breaks, and list structure. Suitable for detail page descriptions.
|
||||
pub fn html_to_description(html: &str) -> String {
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
let mut tag_buf = String::new();
|
||||
|
||||
for ch in html.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
in_tag = true;
|
||||
tag_buf.clear();
|
||||
}
|
||||
'>' if in_tag => {
|
||||
in_tag = false;
|
||||
let tag = tag_buf.trim().to_lowercase();
|
||||
let tag_name = tag.split_whitespace().next().unwrap_or("");
|
||||
match tag_name {
|
||||
"br" | "br/" => result.push('\n'),
|
||||
"/p" => result.push_str("\n\n"),
|
||||
"li" => result.push_str("\n - "),
|
||||
"/ul" | "/ol" => result.push('\n'),
|
||||
s if s.starts_with("/h") => result.push_str("\n\n"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ if in_tag => tag_buf.push(ch),
|
||||
_ => result.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
// Decode HTML entities
|
||||
let decoded = result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ");
|
||||
|
||||
// Clean up: trim lines, collapse multiple blank lines
|
||||
let trimmed: Vec<&str> = decoded.lines().map(|l| l.trim()).collect();
|
||||
let mut cleaned = String::new();
|
||||
let mut prev_blank = false;
|
||||
for line in &trimmed {
|
||||
if line.is_empty() {
|
||||
if !prev_blank && !cleaned.is_empty() {
|
||||
cleaned.push('\n');
|
||||
prev_blank = true;
|
||||
}
|
||||
} else {
|
||||
if prev_blank {
|
||||
cleaned.push('\n');
|
||||
}
|
||||
cleaned.push_str(line);
|
||||
cleaned.push('\n');
|
||||
prev_blank = false;
|
||||
}
|
||||
}
|
||||
|
||||
cleaned.trim().to_string()
|
||||
}
|
||||
|
||||
/// Strip all HTML tags and collapse whitespace. Suitable for short descriptions on tiles.
|
||||
pub fn strip_html(html: &str) -> String {
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::rc::Rc;
|
||||
use gtk::gio;
|
||||
|
||||
use crate::core::catalog;
|
||||
use crate::core::database::{CatalogApp, Database};
|
||||
use crate::core::database::{CatalogApp, CatalogSortOrder, Database};
|
||||
use crate::i18n::i18n;
|
||||
use super::catalog_detail;
|
||||
use super::catalog_tile;
|
||||
@@ -50,7 +50,6 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_start(18)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
|
||||
// Stack for crossfade page transitions
|
||||
@@ -148,29 +147,75 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
featured_section.append(&featured_label);
|
||||
featured_section.append(&carousel_row);
|
||||
|
||||
// --- Category filter chips ---
|
||||
// --- Category filter tiles (wrapping grid) ---
|
||||
let category_box = gtk::FlowBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.homogeneous(false)
|
||||
.min_children_per_line(3)
|
||||
.max_children_per_line(20)
|
||||
.row_spacing(6)
|
||||
.column_spacing(6)
|
||||
.max_children_per_line(6)
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.margin_top(6)
|
||||
.row_spacing(8)
|
||||
.column_spacing(8)
|
||||
.build();
|
||||
|
||||
// --- "All Apps" section ---
|
||||
// --- "All Apps" section header with sort dropdown ---
|
||||
let all_label = gtk::Label::builder()
|
||||
.label(&i18n("All Apps"))
|
||||
.css_classes(["title-2"])
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_start(18)
|
||||
.margin_top(6)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let sort_options = [
|
||||
("Name (A-Z)", CatalogSortOrder::NameAsc),
|
||||
("Name (Z-A)", CatalogSortOrder::NameDesc),
|
||||
("Stars (most first)", CatalogSortOrder::StarsDesc),
|
||||
("Stars (fewest first)", CatalogSortOrder::StarsAsc),
|
||||
("Downloads (most first)", CatalogSortOrder::DownloadsDesc),
|
||||
("Downloads (fewest first)", CatalogSortOrder::DownloadsAsc),
|
||||
("Release date (newest first)", CatalogSortOrder::ReleaseDateDesc),
|
||||
("Release date (oldest first)", CatalogSortOrder::ReleaseDateAsc),
|
||||
];
|
||||
|
||||
let sort_model = gtk::StringList::new(
|
||||
&sort_options.iter().map(|(label, _)| *label).collect::<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
|
||||
let flow_box = gtk::FlowBox::builder()
|
||||
.homogeneous(true)
|
||||
@@ -191,7 +236,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.spacing(48)
|
||||
.build();
|
||||
|
||||
// Enrichment banner (hidden by default, shown by background enrichment)
|
||||
@@ -200,7 +245,6 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
.spacing(8)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.margin_top(6)
|
||||
.visible(false)
|
||||
.build();
|
||||
enrichment_banner.add_css_class("card");
|
||||
@@ -231,8 +275,8 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
content.append(&search_bar);
|
||||
content.append(&enrichment_banner);
|
||||
content.append(&featured_section);
|
||||
content.append(&category_box.clone());
|
||||
content.append(&all_label);
|
||||
content.append(&category_box);
|
||||
content.append(&all_header);
|
||||
content.append(&flow_box);
|
||||
clamp.set_child(Some(&content));
|
||||
|
||||
@@ -301,7 +345,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
|
||||
// Populate categories
|
||||
populate_categories(
|
||||
db, &category_box, &active_category, &flow_box, &search_entry,
|
||||
db, &category_box, &active_category, &active_sort, &flow_box, &search_entry,
|
||||
&featured_section, &all_label, nav_view, &toast_overlay,
|
||||
);
|
||||
|
||||
@@ -310,13 +354,47 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
db, &featured_apps, &featured_page, &featured_stack, &featured_flip,
|
||||
&left_arrow, &right_arrow, nav_view, &toast_overlay,
|
||||
);
|
||||
populate_grid(db, "", None, &flow_box, &all_label, nav_view, &toast_overlay);
|
||||
populate_grid(db, "", None, active_sort.get(), &flow_box, &all_label, nav_view, &toast_overlay);
|
||||
|
||||
// Sort dropdown handler
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let cat_ref = active_category.clone();
|
||||
let sort_ref = active_sort.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let all_label_ref = all_label.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
sort_dropdown.connect_selected_notify(move |dd| {
|
||||
let idx = dd.selected() as usize;
|
||||
let sort_options_local = [
|
||||
CatalogSortOrder::NameAsc,
|
||||
CatalogSortOrder::NameDesc,
|
||||
CatalogSortOrder::StarsDesc,
|
||||
CatalogSortOrder::StarsAsc,
|
||||
CatalogSortOrder::DownloadsDesc,
|
||||
CatalogSortOrder::DownloadsAsc,
|
||||
CatalogSortOrder::ReleaseDateDesc,
|
||||
CatalogSortOrder::ReleaseDateAsc,
|
||||
];
|
||||
let sort = sort_options_local.get(idx).copied().unwrap_or(CatalogSortOrder::NameAsc);
|
||||
sort_ref.set(sort);
|
||||
let query = search_ref.text().to_string();
|
||||
let cat = cat_ref.borrow().clone();
|
||||
populate_grid(
|
||||
&db_ref, &query, cat.as_deref(), sort,
|
||||
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Search handler
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let cat_ref = active_category.clone();
|
||||
let sort_ref = active_sort.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let all_label_ref = all_label.clone();
|
||||
@@ -327,8 +405,8 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
let is_searching = !query.is_empty() || cat.is_some();
|
||||
featured_section_ref.set_visible(!is_searching);
|
||||
populate_grid(
|
||||
&db_ref, &query, cat.as_deref(), &flow_ref,
|
||||
&all_label_ref, &nav_ref, &toast_ref,
|
||||
&db_ref, &query, cat.as_deref(), sort_ref.get(),
|
||||
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -361,6 +439,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
let title_ref = title.clone();
|
||||
let cat_box_ref = category_box.clone();
|
||||
let active_cat_ref = active_category.clone();
|
||||
let active_sort_ref = active_sort.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let featured_apps_ref = featured_apps.clone();
|
||||
let featured_page_ref = featured_page.clone();
|
||||
@@ -383,6 +462,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
let btn_c = btn.clone();
|
||||
let cat_box_c = cat_box_ref.clone();
|
||||
let active_cat_c = active_cat_ref.clone();
|
||||
let active_sort_c = active_sort_ref.clone();
|
||||
let search_c = search_ref.clone();
|
||||
let featured_apps_c = featured_apps_ref.clone();
|
||||
let featured_page_c = featured_page_ref.clone();
|
||||
@@ -407,33 +487,69 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
let (tx, rx) = std::sync::mpsc::channel::<catalog::SyncProgress>();
|
||||
|
||||
// Listen for progress on the main thread
|
||||
// Track current source info for progress text
|
||||
let progress_listen = progress_c.clone();
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(50), move || {
|
||||
let current_source_name: Rc<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() {
|
||||
match progress {
|
||||
catalog::SyncProgress::FetchingFeed => {
|
||||
progress_listen.set_fraction(0.0);
|
||||
progress_listen.set_text(Some("Fetching catalog feed..."));
|
||||
}
|
||||
catalog::SyncProgress::FeedFetched { total } => {
|
||||
progress_listen.set_fraction(0.05);
|
||||
progress_listen.set_text(Some(&format!("Found {} apps, caching icons...", total)));
|
||||
}
|
||||
catalog::SyncProgress::CachingIcon { current, total, .. } => {
|
||||
let frac = 0.05 + 0.85 * (current as f64 / total.max(1) as f64);
|
||||
catalog::SyncProgress::SourceStarted { ref name, source_index, source_count } => {
|
||||
*src_name.borrow_mut() = name.clone();
|
||||
// Divide progress bar evenly across sources
|
||||
let span = 1.0 / source_count.max(1) as f64;
|
||||
src_base.set(source_index as f64 * span);
|
||||
src_span.set(span);
|
||||
let frac = src_base.get();
|
||||
progress_listen.set_fraction(frac);
|
||||
progress_listen.set_text(Some(
|
||||
&format!("Caching icons ({}/{})", current, total),
|
||||
&format!("Syncing {}...", name),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::FetchingFeed => {
|
||||
let name = src_name.borrow();
|
||||
progress_listen.set_fraction(src_base.get());
|
||||
progress_listen.set_text(Some(
|
||||
&format!("{}: Fetching feed...", &*name),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::FeedFetched { total } => {
|
||||
let name = src_name.borrow();
|
||||
progress_listen.set_fraction(src_base.get() + src_span.get() * 0.05);
|
||||
progress_listen.set_text(Some(
|
||||
&format!("{}: Found {} apps", &*name, total),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::CachingIcon { current, total, .. } => {
|
||||
let name = src_name.borrow();
|
||||
let inner = 0.05 + 0.85 * (current as f64 / total.max(1) as f64);
|
||||
progress_listen.set_fraction(src_base.get() + src_span.get() * inner);
|
||||
progress_listen.set_text(Some(
|
||||
&format!("{}: Caching icons ({}/{})", &*name, current, total),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::SavingApps { current, total } => {
|
||||
let frac = 0.90 + 0.10 * (current as f64 / total.max(1) as f64);
|
||||
progress_listen.set_fraction(frac);
|
||||
let name = src_name.borrow();
|
||||
let inner = 0.90 + 0.10 * (current as f64 / total.max(1) as f64);
|
||||
progress_listen.set_fraction(src_base.get() + src_span.get() * inner);
|
||||
progress_listen.set_text(Some(
|
||||
&format!("Saving apps ({}/{})", current, total),
|
||||
&format!("{}: Saving ({}/{})", &*name, current, total),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::Done { .. } => {
|
||||
// Single source done - don't break, more sources may follow
|
||||
let name = src_name.borrow();
|
||||
progress_listen.set_fraction(src_base.get() + src_span.get());
|
||||
progress_listen.set_text(Some(
|
||||
&format!("{}: Complete", &*name),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::AllDone => {
|
||||
progress_listen.set_fraction(1.0);
|
||||
progress_listen.set_text(Some("Complete"));
|
||||
return glib::ControlFlow::Break;
|
||||
@@ -444,7 +560,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}});
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let result = gio::spawn_blocking(move || {
|
||||
@@ -452,13 +568,31 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
if let Some(ref db) = db_bg {
|
||||
catalog::ensure_default_sources(db);
|
||||
let sources = catalog::get_sources(db);
|
||||
if let Some(source) = sources.first() {
|
||||
catalog::sync_catalog_with_progress(db, source, &move |p| {
|
||||
tx.send(p).ok();
|
||||
}).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err("No catalog sources configured".to_string())
|
||||
if sources.is_empty() {
|
||||
return Err("No catalog sources configured".to_string());
|
||||
}
|
||||
// Sync all sources in order (OCS first as primary, then secondary)
|
||||
let enabled_sources: Vec<_> = sources.iter()
|
||||
.filter(|s| s.enabled)
|
||||
.collect();
|
||||
let source_count = enabled_sources.len() as u32;
|
||||
let mut total_count = 0u32;
|
||||
for (i, source) in enabled_sources.iter().enumerate() {
|
||||
tx.send(catalog::SyncProgress::SourceStarted {
|
||||
name: source.name.clone(),
|
||||
source_index: i as u32,
|
||||
source_count,
|
||||
}).ok();
|
||||
let tx_ref = tx.clone();
|
||||
match catalog::sync_catalog_with_progress(db, source, &move |p| {
|
||||
tx_ref.send(p).ok();
|
||||
}) {
|
||||
Ok(count) => total_count += count,
|
||||
Err(e) => eprintln!("Failed to sync source '{}': {}", source.name, e),
|
||||
}
|
||||
}
|
||||
tx.send(catalog::SyncProgress::AllDone).ok();
|
||||
Ok(total_count)
|
||||
} else {
|
||||
Err("Failed to open database".to_string())
|
||||
}
|
||||
@@ -480,7 +614,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
update_catalog_subtitle(&title_c, count_after);
|
||||
stack_c.set_visible_child_name("results");
|
||||
populate_categories(
|
||||
&db_c, &cat_box_c, &active_cat_c, &flow_c, &search_c,
|
||||
&db_c, &cat_box_c, &active_cat_c, &active_sort_c, &flow_c, &search_c,
|
||||
&featured_section_c, &all_label_c,
|
||||
&nav_c, &toast_c,
|
||||
);
|
||||
@@ -490,7 +624,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
&left_arrow_c, &right_arrow_c, &nav_c, &toast_c,
|
||||
);
|
||||
populate_grid(
|
||||
&db_c, "", None, &flow_c, &all_label_c, &nav_c, &toast_c,
|
||||
&db_c, "", None, active_sort_c.get(), &flow_c, &all_label_c, &nav_c, &toast_c,
|
||||
);
|
||||
|
||||
let settings = gio::Settings::new(crate::config::APP_ID);
|
||||
@@ -519,6 +653,8 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
refresh_btn.emit_clicked();
|
||||
}
|
||||
|
||||
widgets::apply_pointer_cursors(&toolbar_view);
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title(&i18n("Catalog"))
|
||||
.tag("catalog-browse")
|
||||
@@ -679,6 +815,7 @@ fn populate_grid(
|
||||
db: &Rc<Database>,
|
||||
query: &str,
|
||||
category: Option<&str>,
|
||||
sort: CatalogSortOrder,
|
||||
flow_box: >k::FlowBox,
|
||||
all_label: >k::Label,
|
||||
_nav_view: &adw::NavigationView,
|
||||
@@ -689,7 +826,7 @@ fn populate_grid(
|
||||
flow_box.remove(&child);
|
||||
}
|
||||
|
||||
let results = db.search_catalog(query, category, 200).unwrap_or_default();
|
||||
let results = db.search_catalog(query, category, 200, sort).unwrap_or_default();
|
||||
|
||||
if results.is_empty() {
|
||||
all_label.set_label(&i18n("No results"));
|
||||
@@ -711,10 +848,54 @@ fn populate_grid(
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a FreeDesktop category name to (icon_name, color_css_class).
|
||||
fn category_meta(name: &str) -> (&'static str, &'static str) {
|
||||
match name.to_lowercase().as_str() {
|
||||
"audio" => ("audio-x-generic-symbolic", "cat-purple"),
|
||||
"audiovideo" | "video" => ("camera-video-symbolic", "cat-red"),
|
||||
"game" => ("input-gaming-symbolic", "cat-green"),
|
||||
"graphics" => ("image-x-generic-symbolic", "cat-orange"),
|
||||
"development" => ("utilities-terminal-symbolic", "cat-blue"),
|
||||
"education" => ("accessories-dictionary-symbolic", "cat-amber"),
|
||||
"network" => ("network-workgroup-symbolic", "cat-purple"),
|
||||
"office" => ("x-office-document-symbolic", "cat-amber"),
|
||||
"science" => ("accessories-calculator-symbolic", "cat-blue"),
|
||||
"system" => ("emblem-system-symbolic", "cat-neutral"),
|
||||
"utility" => ("applications-utilities-symbolic", "cat-green"),
|
||||
_ => ("application-x-executable-symbolic", "cat-neutral"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a category tile toggle button with icon and label.
|
||||
fn build_category_tile(label_text: &str, icon_name: &str, color_class: &str, active: bool) -> gtk::ToggleButton {
|
||||
let icon = gtk::Image::from_icon_name(icon_name);
|
||||
icon.set_pixel_size(24);
|
||||
|
||||
let label = gtk::Label::new(Some(label_text));
|
||||
label.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
||||
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(10)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
inner.append(&icon);
|
||||
inner.append(&label);
|
||||
|
||||
let btn = gtk::ToggleButton::builder()
|
||||
.child(&inner)
|
||||
.active(active)
|
||||
.css_classes(["flat", "category-tile", color_class])
|
||||
.build();
|
||||
widgets::set_pointer_cursor(&btn);
|
||||
btn
|
||||
}
|
||||
|
||||
fn populate_categories(
|
||||
db: &Rc<Database>,
|
||||
category_box: >k::FlowBox,
|
||||
active_category: &Rc<RefCell<Option<String>>>,
|
||||
active_sort: &Rc<std::cell::Cell<CatalogSortOrder>>,
|
||||
flow_box: >k::FlowBox,
|
||||
search_entry: >k::SearchEntry,
|
||||
featured_section: >k::Box,
|
||||
@@ -732,26 +913,23 @@ fn populate_categories(
|
||||
return;
|
||||
}
|
||||
|
||||
let all_btn = gtk::ToggleButton::builder()
|
||||
.label(&i18n("All"))
|
||||
.active(true)
|
||||
.css_classes(["pill"])
|
||||
.build();
|
||||
category_box.insert(&all_btn, -1);
|
||||
let all_btn = build_category_tile(
|
||||
&i18n("All"), "view-grid-symbolic", "cat-accent", true,
|
||||
);
|
||||
category_box.append(&all_btn);
|
||||
|
||||
let buttons: Rc<RefCell<Vec<gtk::ToggleButton>>> =
|
||||
Rc::new(RefCell::new(vec![all_btn.clone()]));
|
||||
|
||||
for (cat, _count) in categories.iter().take(10) {
|
||||
let btn = gtk::ToggleButton::builder()
|
||||
.label(cat)
|
||||
.css_classes(["pill"])
|
||||
.build();
|
||||
category_box.insert(&btn, -1);
|
||||
for (cat, _count) in categories.iter().take(12) {
|
||||
let (icon_name, color_class) = category_meta(cat);
|
||||
let btn = build_category_tile(cat, icon_name, color_class, false);
|
||||
category_box.append(&btn);
|
||||
buttons.borrow_mut().push(btn.clone());
|
||||
|
||||
let cat_str = cat.clone();
|
||||
let active_ref = active_category.clone();
|
||||
let sort_ref = active_sort.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let db_ref = db.clone();
|
||||
@@ -771,8 +949,8 @@ fn populate_categories(
|
||||
featured_section_ref.set_visible(false);
|
||||
let query = search_ref.text().to_string();
|
||||
populate_grid(
|
||||
&db_ref, &query, Some(&cat_str), &flow_ref,
|
||||
&all_label_ref, &nav_ref, &toast_ref,
|
||||
&db_ref, &query, Some(&cat_str), sort_ref.get(),
|
||||
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -780,6 +958,7 @@ fn populate_categories(
|
||||
|
||||
{
|
||||
let active_ref = active_category.clone();
|
||||
let sort_ref = active_sort.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let db_ref = db.clone();
|
||||
@@ -799,8 +978,8 @@ fn populate_categories(
|
||||
featured_section_ref.set_visible(true);
|
||||
let query = search_ref.text().to_string();
|
||||
populate_grid(
|
||||
&db_ref, &query, None, &flow_ref,
|
||||
&all_label_ref, &nav_ref, &toast_ref,
|
||||
&db_ref, &query, None, sort_ref.get(),
|
||||
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ pub fn build_dashboard_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&scrolled));
|
||||
widgets::apply_pointer_cursors(&toolbar);
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Dashboard")
|
||||
|
||||
@@ -197,6 +197,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&toast_overlay));
|
||||
widgets::apply_pointer_cursors(&toolbar);
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title(name)
|
||||
|
||||
@@ -359,6 +359,7 @@ impl LibraryView {
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
toolbar_view.add_top_bar(&header_bar);
|
||||
toolbar_view.set_content(Some(&content_box));
|
||||
widgets::apply_pointer_cursors(&toolbar_view);
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Driftwood")
|
||||
|
||||
@@ -14,6 +14,7 @@ pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
|
||||
|
||||
dialog.add(&build_general_page(&settings, &dialog));
|
||||
dialog.add(&build_updates_page(&settings));
|
||||
super::widgets::apply_pointer_cursors(&dialog);
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
toolbar_view.add_top_bar(&header);
|
||||
toolbar_view.set_content(Some(&toast_overlay));
|
||||
widgets::apply_pointer_cursors(&toolbar_view);
|
||||
|
||||
toolbar_view
|
||||
}
|
||||
|
||||
@@ -55,6 +55,37 @@ fn generate_letter_icon_css() -> String {
|
||||
css
|
||||
}
|
||||
|
||||
/// Set the pointer (hand) cursor on a widget, so it looks clickable on hover.
|
||||
pub fn set_pointer_cursor(widget: &impl IsA<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.
|
||||
/// Style classes: "success", "warning", "error", "info", "neutral"
|
||||
pub fn status_badge(text: &str, style_class: &str) -> gtk::Label {
|
||||
@@ -439,3 +470,163 @@ pub fn announce(container: &impl gtk::prelude::IsA<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