Add AppImageHub in-app catalog browser with search, categories, and install
This commit is contained in:
@@ -88,6 +88,42 @@ pub struct SystemModification {
|
||||
pub previous_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CatalogApp {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub categories: Option<String>,
|
||||
pub download_url: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub license: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CatalogSourceRecord {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub source_type: String,
|
||||
pub enabled: bool,
|
||||
pub last_synced: Option<String>,
|
||||
pub app_count: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CatalogAppRecord {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub categories: Option<String>,
|
||||
pub latest_version: Option<String>,
|
||||
pub download_url: String,
|
||||
pub icon_url: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub file_size: Option<i64>,
|
||||
pub architecture: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrphanedEntry {
|
||||
pub id: i64,
|
||||
@@ -641,6 +677,8 @@ impl Database {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_catalog_apps_source
|
||||
ON catalog_apps(source_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_apps_source_name
|
||||
ON catalog_apps(source_id, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_sandbox_profiles_app
|
||||
ON sandbox_profiles(app_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_updates_appimage
|
||||
@@ -1954,6 +1992,236 @@ impl Database {
|
||||
self.conn.execute("DELETE FROM system_modifications WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Catalog methods ---
|
||||
|
||||
pub fn upsert_catalog_source(
|
||||
&self,
|
||||
name: &str,
|
||||
url: &str,
|
||||
source_type: &str,
|
||||
) -> SqlResult<i64> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO catalog_sources (name, url, source_type, last_synced)
|
||||
VALUES (?1, ?2, ?3, datetime('now'))
|
||||
ON CONFLICT(url) DO UPDATE SET last_synced = datetime('now')",
|
||||
params![name, url, source_type],
|
||||
)?;
|
||||
self.conn.query_row(
|
||||
"SELECT id FROM catalog_sources WHERE url = ?1",
|
||||
params![url],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn upsert_catalog_app(
|
||||
&self,
|
||||
source_id: i64,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
categories: Option<&str>,
|
||||
download_url: &str,
|
||||
icon_url: Option<&str>,
|
||||
homepage: Option<&str>,
|
||||
license: Option<&str>,
|
||||
) -> SqlResult<i64> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO catalog_apps (source_id, name, description, categories, download_url, icon_url, homepage, cached_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'))
|
||||
ON CONFLICT(source_id, name) DO UPDATE SET
|
||||
description = COALESCE(?3, description),
|
||||
categories = COALESCE(?4, categories),
|
||||
download_url = ?5,
|
||||
icon_url = COALESCE(?6, icon_url),
|
||||
homepage = COALESCE(?7, homepage),
|
||||
cached_at = datetime('now')",
|
||||
params![source_id, name, description, categories, download_url, icon_url, homepage],
|
||||
)?;
|
||||
// Store license as architecture field (reusing available column)
|
||||
// until a proper column is added
|
||||
if let Some(lic) = license {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_apps SET architecture = ?1 WHERE source_id = ?2 AND name = ?3",
|
||||
params![lic, source_id, name],
|
||||
)?;
|
||||
}
|
||||
self.conn.query_row(
|
||||
"SELECT id FROM catalog_apps WHERE source_id = ?1 AND name = ?2",
|
||||
params![source_id, name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn search_catalog(
|
||||
&self,
|
||||
query: &str,
|
||||
category: Option<&str>,
|
||||
limit: i32,
|
||||
) -> SqlResult<Vec<CatalogApp>> {
|
||||
let mut sql = String::from(
|
||||
"SELECT id, name, description, categories, download_url, icon_url, homepage, architecture
|
||||
FROM catalog_apps WHERE 1=1"
|
||||
);
|
||||
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)");
|
||||
params_list.push(Box::new(format!("%{}%", query)));
|
||||
}
|
||||
|
||||
if let Some(cat) = category {
|
||||
let idx = params_list.len() + 1;
|
||||
sql.push_str(&format!(" AND categories LIKE ?{}", idx));
|
||||
params_list.push(Box::new(format!("%{}%", cat)));
|
||||
}
|
||||
|
||||
sql.push_str(&format!(" ORDER BY name LIMIT {}", 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)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
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, architecture
|
||||
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)?,
|
||||
})
|
||||
},
|
||||
);
|
||||
match result {
|
||||
Ok(app) => Ok(Some(app)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_catalog_categories(&self) -> SqlResult<Vec<(String, u32)>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT categories FROM catalog_apps WHERE categories IS NOT NULL AND categories != ''"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
||||
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
if let Ok(cats_str) = row {
|
||||
for cat in cats_str.split(';').filter(|s| !s.is_empty()) {
|
||||
*counts.entry(cat.to_string()).or_insert(0u32) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result: Vec<(String, u32)> = counts.into_iter().collect();
|
||||
result.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn catalog_app_count(&self) -> SqlResult<i64> {
|
||||
self.conn.query_row("SELECT COUNT(*) FROM catalog_apps", [], |row| row.get(0))
|
||||
}
|
||||
|
||||
pub fn insert_catalog_app(
|
||||
&self,
|
||||
source_id: i64,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
categories: Option<&str>,
|
||||
latest_version: Option<&str>,
|
||||
download_url: &str,
|
||||
icon_url: Option<&str>,
|
||||
homepage: Option<&str>,
|
||||
file_size: Option<i64>,
|
||||
architecture: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO catalog_apps
|
||||
(source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture, cached_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))",
|
||||
params![source_id, name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn search_catalog_apps(&self, query: &str) -> SqlResult<Vec<CatalogAppRecord>> {
|
||||
let pattern = format!("%{}%", query);
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT name, description, categories, latest_version, download_url, icon_url, homepage, file_size, architecture
|
||||
FROM catalog_apps
|
||||
WHERE name LIKE ?1 OR description LIKE ?1
|
||||
ORDER BY name
|
||||
LIMIT 50"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![pattern], |row| {
|
||||
Ok(CatalogAppRecord {
|
||||
name: row.get(0)?,
|
||||
description: row.get(1)?,
|
||||
categories: row.get(2)?,
|
||||
latest_version: row.get(3)?,
|
||||
download_url: row.get(4)?,
|
||||
icon_url: row.get(5)?,
|
||||
homepage: row.get(6)?,
|
||||
file_size: row.get(7)?,
|
||||
architecture: row.get(8)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn update_catalog_source_sync(&self, source_id: i64, app_count: i32) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE catalog_sources SET last_synced = datetime('now'), app_count = ?2 WHERE id = ?1",
|
||||
params![source_id, app_count],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_catalog_sources(&self) -> SqlResult<Vec<CatalogSourceRecord>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, name, url, source_type, enabled, last_synced, app_count FROM catalog_sources ORDER BY name"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(CatalogSourceRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
url: row.get(2)?,
|
||||
source_type: row.get(3)?,
|
||||
enabled: row.get::<_, i32>(4).unwrap_or(1) != 0,
|
||||
last_synced: row.get(5)?,
|
||||
app_count: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user