Add tags, export/import, and changelog features
- Tag editor in detail view with add/remove pill chips - Tag filter chips in library view for filtering by tag - Shared backup module for app list export/import (JSON v2) - CLI export/import refactored to use shared module - GUI export/import via file picker dialogs in hamburger menu - GitHub release history enrichment for catalog apps - Changelog preview in updates view with expandable rows - DB migration v19 for catalog release_history column
This commit is contained in:
221
src/cli.rs
221
src/cli.rs
@@ -3,6 +3,7 @@ use glib::ExitCode;
|
|||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::core::backup;
|
||||||
use crate::core::database::Database;
|
use crate::core::database::Database;
|
||||||
use crate::core::discovery;
|
use crate::core::discovery;
|
||||||
use crate::core::duplicates;
|
use crate::core::duplicates;
|
||||||
@@ -885,209 +886,39 @@ fn cmd_verify(path: &str, expected_sha256: Option<&str>) -> ExitCode {
|
|||||||
// --- Export/Import library ---
|
// --- Export/Import library ---
|
||||||
|
|
||||||
fn cmd_export(db: &Database, output: Option<&str>) -> ExitCode {
|
fn cmd_export(db: &Database, output: Option<&str>) -> ExitCode {
|
||||||
let records = match db.get_all_appimages() {
|
let path = output.unwrap_or("driftwood-apps.json");
|
||||||
Ok(r) => r,
|
let export_path = std::path::Path::new(path);
|
||||||
|
|
||||||
|
match backup::export_app_list(db, export_path) {
|
||||||
|
Ok(count) => {
|
||||||
|
eprintln!("Exported {} AppImages to {}", count, path);
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error: {}", e);
|
eprintln!("Error: {}", e);
|
||||||
return ExitCode::FAILURE;
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let appimages: Vec<serde_json::Value> = records
|
|
||||||
.iter()
|
|
||||||
.map(|r| {
|
|
||||||
serde_json::json!({
|
|
||||||
"path": r.path,
|
|
||||||
"app_name": r.app_name,
|
|
||||||
"app_version": r.app_version,
|
|
||||||
"integrated": r.integrated,
|
|
||||||
"notes": r.notes,
|
|
||||||
"categories": r.categories,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let export_data = serde_json::json!({
|
|
||||||
"version": 1,
|
|
||||||
"exported_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
|
||||||
"appimages": appimages,
|
|
||||||
});
|
|
||||||
|
|
||||||
let json_str = match serde_json::to_string_pretty(&export_data) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error serializing export data: {}", e);
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(path) = output {
|
|
||||||
if let Err(e) = std::fs::write(path, &json_str) {
|
|
||||||
eprintln!("Error writing to {}: {}", path, e);
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
println!("{}", json_str);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("Exported {} AppImages", records.len());
|
|
||||||
ExitCode::SUCCESS
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_import(db: &Database, file: &str) -> ExitCode {
|
fn cmd_import(db: &Database, file: &str) -> ExitCode {
|
||||||
let content = match std::fs::read_to_string(file) {
|
let import_path = std::path::Path::new(file);
|
||||||
Ok(c) => c,
|
|
||||||
|
match backup::import_app_list(db, import_path) {
|
||||||
|
Ok(result) => {
|
||||||
|
eprintln!("Matched and merged metadata for {} apps", result.matched);
|
||||||
|
if !result.missing.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"{} apps not found in library: {}",
|
||||||
|
result.missing.len(),
|
||||||
|
result.missing.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Error reading {}: {}", file, e);
|
eprintln!("Error: {}", e);
|
||||||
return ExitCode::FAILURE;
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let data: serde_json::Value = match serde_json::from_str(&content) {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Error parsing JSON: {}", e);
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let entries = match data.get("appimages").and_then(|a| a.as_array()) {
|
|
||||||
Some(arr) => arr,
|
|
||||||
None => {
|
|
||||||
eprintln!("Error: JSON missing 'appimages' array");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let total = entries.len();
|
|
||||||
let mut imported = 0u32;
|
|
||||||
let mut skipped = 0u32;
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let path_str = match entry.get("path").and_then(|p| p.as_str()) {
|
|
||||||
Some(p) => p,
|
|
||||||
None => {
|
|
||||||
skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let file_path = std::path::Path::new(path_str);
|
|
||||||
if !file_path.exists() {
|
|
||||||
skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that the file is actually an AppImage
|
|
||||||
let appimage_type = match discovery::detect_appimage(file_path) {
|
|
||||||
Some(t) => t,
|
|
||||||
None => {
|
|
||||||
eprintln!(" Skipping {} - not a valid AppImage", path_str);
|
|
||||||
skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let metadata = std::fs::metadata(file_path);
|
|
||||||
let size_bytes = metadata.as_ref().map(|m| m.len() as i64).unwrap_or(0);
|
|
||||||
let is_executable = metadata
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
m.permissions().mode() & 0o111 != 0
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
let filename = file_path
|
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let file_modified = metadata
|
|
||||||
.as_ref()
|
|
||||||
.ok()
|
|
||||||
.and_then(|m| m.modified().ok())
|
|
||||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
||||||
.and_then(|dur| {
|
|
||||||
chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0)
|
|
||||||
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
|
||||||
});
|
|
||||||
|
|
||||||
let id = match db.upsert_appimage(
|
|
||||||
path_str,
|
|
||||||
&filename,
|
|
||||||
Some(appimage_type.as_i32()),
|
|
||||||
size_bytes,
|
|
||||||
is_executable,
|
|
||||||
file_modified.as_deref(),
|
|
||||||
) {
|
|
||||||
Ok(id) => id,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(" Error registering {}: {}", path_str, e);
|
|
||||||
skipped += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Restore metadata fields from the export
|
|
||||||
let app_name = entry.get("app_name").and_then(|v| v.as_str());
|
|
||||||
let app_version = entry.get("app_version").and_then(|v| v.as_str());
|
|
||||||
let categories = entry.get("categories").and_then(|v| v.as_str());
|
|
||||||
|
|
||||||
if app_name.is_some() || app_version.is_some() {
|
|
||||||
db.update_metadata(
|
|
||||||
id,
|
|
||||||
app_name,
|
|
||||||
app_version,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
categories,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore notes if present
|
|
||||||
if let Some(notes_str) = entry.get("notes").and_then(|v| v.as_str()) {
|
|
||||||
db.update_notes(id, Some(notes_str)).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it was integrated in the export, integrate it now
|
|
||||||
let was_integrated = entry
|
|
||||||
.get("integrated")
|
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if was_integrated {
|
|
||||||
// Need the full record to integrate
|
|
||||||
if let Ok(Some(record)) = db.get_appimage_by_id(id) {
|
|
||||||
if !record.integrated {
|
|
||||||
match integrator::integrate_tracked(&record, &db) {
|
|
||||||
Ok(result) => {
|
|
||||||
db.set_integrated(
|
|
||||||
id,
|
|
||||||
true,
|
|
||||||
Some(&result.desktop_file_path.to_string_lossy()),
|
|
||||||
).ok();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(" Warning: could not integrate {}: {}", path_str, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
imported += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"Imported {} of {} AppImages ({} skipped - file not found)",
|
|
||||||
imported,
|
|
||||||
total,
|
|
||||||
skipped,
|
|
||||||
);
|
|
||||||
|
|
||||||
ExitCode::SUCCESS
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -407,6 +407,197 @@ fn read_manifest(archive_path: &Path) -> Result<BackupManifest, BackupError> {
|
|||||||
.map_err(|e| BackupError::Io(format!("Invalid manifest: {}", e)))
|
.map_err(|e| BackupError::Io(format!("Invalid manifest: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== App list export/import (JSON v2) =====
|
||||||
|
|
||||||
|
/// Result of importing an app list.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ImportResult {
|
||||||
|
pub matched: usize,
|
||||||
|
pub missing: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the app list to a JSON file (v2 format with extended fields).
|
||||||
|
pub fn export_app_list(db: &Database, path: &Path) -> Result<usize, BackupError> {
|
||||||
|
let records = db.get_all_appimages()
|
||||||
|
.map_err(|e| BackupError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
let appimages: Vec<serde_json::Value> = records
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"path": r.path,
|
||||||
|
"app_name": r.app_name,
|
||||||
|
"app_version": r.app_version,
|
||||||
|
"integrated": r.integrated,
|
||||||
|
"notes": r.notes,
|
||||||
|
"categories": r.categories,
|
||||||
|
"tags": r.tags,
|
||||||
|
"pinned": r.pinned,
|
||||||
|
"launch_args": r.launch_args,
|
||||||
|
"sandbox_mode": r.sandbox_mode,
|
||||||
|
"autostart": r.autostart,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let count = appimages.len();
|
||||||
|
let export_data = serde_json::json!({
|
||||||
|
"version": 2,
|
||||||
|
"exported_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||||||
|
"appimages": appimages,
|
||||||
|
});
|
||||||
|
|
||||||
|
let json_str = serde_json::to_string_pretty(&export_data)
|
||||||
|
.map_err(|e| BackupError::Io(e.to_string()))?;
|
||||||
|
fs::write(path, &json_str)
|
||||||
|
.map_err(|e| BackupError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import an app list from a JSON file (supports both v1 and v2 formats).
|
||||||
|
/// Matches apps by path. Merges metadata for matched apps (only fills empty fields).
|
||||||
|
pub fn import_app_list(db: &Database, path: &Path) -> Result<ImportResult, BackupError> {
|
||||||
|
let content = fs::read_to_string(path)
|
||||||
|
.map_err(|e| BackupError::Io(e.to_string()))?;
|
||||||
|
|
||||||
|
let data: serde_json::Value = serde_json::from_str(&content)
|
||||||
|
.map_err(|e| BackupError::Io(format!("Invalid JSON: {}", e)))?;
|
||||||
|
|
||||||
|
let entries = data.get("appimages")
|
||||||
|
.and_then(|a| a.as_array())
|
||||||
|
.ok_or_else(|| BackupError::Io("Missing 'appimages' array".to_string()))?;
|
||||||
|
|
||||||
|
let mut matched = 0usize;
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path_str = match entry.get("path").and_then(|p| p.as_str()) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to find this app in the database by path
|
||||||
|
let existing = db.get_appimage_by_path(path_str)
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let record = match existing {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
// App not in DB - check if file exists on disk
|
||||||
|
if !Path::new(path_str).exists() {
|
||||||
|
missing.push(path_str.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
missing.push(path_str.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = record.id;
|
||||||
|
|
||||||
|
// Merge metadata: only overwrite if existing is empty/None and import has a value
|
||||||
|
let app_name = merge_str_field(record.app_name.as_deref(), entry, "app_name");
|
||||||
|
let app_version = merge_str_field(record.app_version.as_deref(), entry, "app_version");
|
||||||
|
let categories = merge_str_field(record.categories.as_deref(), entry, "categories");
|
||||||
|
|
||||||
|
if app_name.is_some() || app_version.is_some() || categories.is_some() {
|
||||||
|
db.update_metadata(
|
||||||
|
id,
|
||||||
|
app_name.as_deref().or(record.app_name.as_deref()),
|
||||||
|
app_version.as_deref().or(record.app_version.as_deref()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
categories.as_deref().or(record.categories.as_deref()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge notes
|
||||||
|
if record.notes.as_ref().map_or(true, |n| n.is_empty()) {
|
||||||
|
if let Some(notes) = entry.get("notes").and_then(|v| v.as_str()) {
|
||||||
|
if !notes.is_empty() {
|
||||||
|
db.update_notes(id, Some(notes)).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge tags (union of existing + imported, no duplicates)
|
||||||
|
if let Some(import_tags) = entry.get("tags").and_then(|v| v.as_str()) {
|
||||||
|
if !import_tags.is_empty() {
|
||||||
|
let mut tag_set: std::collections::BTreeSet<String> = record
|
||||||
|
.tags
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.split(',')
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for tag in import_tags.split(',') {
|
||||||
|
let trimmed = tag.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
tag_set.insert(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged = tag_set.into_iter().collect::<Vec<_>>().join(",");
|
||||||
|
db.update_tags(id, Some(&merged)).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge pinned (only set if imported says true and current is false)
|
||||||
|
if !record.pinned {
|
||||||
|
if let Some(true) = entry.get("pinned").and_then(|v| v.as_bool()) {
|
||||||
|
db.set_pinned(id, true).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge launch_args
|
||||||
|
if record.launch_args.as_ref().map_or(true, |a| a.is_empty()) {
|
||||||
|
if let Some(args) = entry.get("launch_args").and_then(|v| v.as_str()) {
|
||||||
|
if !args.is_empty() {
|
||||||
|
db.update_launch_args(id, Some(args)).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge sandbox_mode
|
||||||
|
if record.sandbox_mode.is_none() {
|
||||||
|
if let Some(mode) = entry.get("sandbox_mode").and_then(|v| v.as_str()) {
|
||||||
|
if !mode.is_empty() {
|
||||||
|
db.update_sandbox_mode(id, Some(mode)).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge autostart (only set if imported says true and current is false)
|
||||||
|
if !record.autostart {
|
||||||
|
if let Some(true) = entry.get("autostart").and_then(|v| v.as_bool()) {
|
||||||
|
db.set_autostart(id, true).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matched += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ImportResult { matched, missing })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: returns Some(imported_value) only if existing is empty and import has a non-empty value.
|
||||||
|
fn merge_str_field(existing: Option<&str>, entry: &serde_json::Value, key: &str) -> Option<String> {
|
||||||
|
if existing.map_or(false, |s| !s.is_empty()) {
|
||||||
|
return None; // existing has data, don't overwrite
|
||||||
|
}
|
||||||
|
entry.get(key)
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ pub struct CatalogApp {
|
|||||||
pub ocs_arch: Option<String>,
|
pub ocs_arch: Option<String>,
|
||||||
pub ocs_md5sum: Option<String>,
|
pub ocs_md5sum: Option<String>,
|
||||||
pub ocs_comments: Option<i64>,
|
pub ocs_comments: Option<i64>,
|
||||||
|
pub release_history: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -484,6 +485,10 @@ impl Database {
|
|||||||
self.migrate_to_v18()?;
|
self.migrate_to_v18()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if current_version < 19 {
|
||||||
|
self.migrate_to_v19()?;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure all expected columns exist (repairs DBs where a migration
|
// Ensure all expected columns exist (repairs DBs where a migration
|
||||||
// was updated after it had already run on this database)
|
// was updated after it had already run on this database)
|
||||||
self.ensure_columns()?;
|
self.ensure_columns()?;
|
||||||
@@ -1050,6 +1055,18 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_to_v19(&self) -> SqlResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"ALTER TABLE catalog_apps ADD COLUMN release_history TEXT",
|
||||||
|
[],
|
||||||
|
).ok();
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE schema_version SET version = ?1",
|
||||||
|
params![19],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn upsert_appimage(
|
pub fn upsert_appimage(
|
||||||
&self,
|
&self,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -1221,7 +1238,8 @@ impl Database {
|
|||||||
github_enriched_at, github_download_url, github_release_assets, github_description, github_readme,
|
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_id, ocs_downloads, ocs_score, ocs_typename, ocs_personid, ocs_description, ocs_summary,
|
||||||
ocs_version, ocs_tags, ocs_changed, ocs_preview_url,
|
ocs_version, ocs_tags, ocs_changed, ocs_preview_url,
|
||||||
ocs_detailpage, ocs_created, ocs_downloadname, ocs_downloadsize, ocs_arch, ocs_md5sum, ocs_comments";
|
ocs_detailpage, ocs_created, ocs_downloadname, ocs_downloadsize, ocs_arch, ocs_md5sum, ocs_comments,
|
||||||
|
release_history";
|
||||||
|
|
||||||
/// SQL filter that deduplicates catalog apps by lowercase name.
|
/// SQL filter that deduplicates catalog apps by lowercase name.
|
||||||
/// Keeps the OCS entry when both OCS and secondary source entries exist for the same name.
|
/// Keeps the OCS entry when both OCS and secondary source entries exist for the same name.
|
||||||
@@ -1277,6 +1295,7 @@ impl Database {
|
|||||||
ocs_arch: row.get(35).unwrap_or(None),
|
ocs_arch: row.get(35).unwrap_or(None),
|
||||||
ocs_md5sum: row.get(36).unwrap_or(None),
|
ocs_md5sum: row.get(36).unwrap_or(None),
|
||||||
ocs_comments: row.get(37).unwrap_or(None),
|
ocs_comments: row.get(37).unwrap_or(None),
|
||||||
|
release_history: row.get(38).unwrap_or(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2066,6 +2085,27 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get all distinct tags used across all installed apps.
|
||||||
|
/// Returns a sorted, deduplicated list of tag strings.
|
||||||
|
pub fn get_all_tags(&self) -> SqlResult<Vec<String>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT tags FROM appimages WHERE tags IS NOT NULL AND tags != ''"
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
||||||
|
let mut tag_set = std::collections::BTreeSet::new();
|
||||||
|
for row in rows {
|
||||||
|
if let Ok(tags_str) = row {
|
||||||
|
for tag in tags_str.split(',') {
|
||||||
|
let trimmed = tag.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
tag_set.insert(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(tag_set.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_pinned(&self, id: i64, pinned: bool) -> SqlResult<()> {
|
pub fn set_pinned(&self, id: i64, pinned: bool) -> SqlResult<()> {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE appimages SET pinned = ?2 WHERE id = ?1",
|
"UPDATE appimages SET pinned = ?2 WHERE id = ?1",
|
||||||
@@ -2479,6 +2519,23 @@ impl Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Look up release history from catalog for an installed app (by name).
|
||||||
|
/// Returns the release_history JSON string if a matching catalog app has one.
|
||||||
|
pub fn get_catalog_release_history_by_name(&self, app_name: &str) -> SqlResult<Option<String>> {
|
||||||
|
let result = self.conn.query_row(
|
||||||
|
"SELECT release_history FROM catalog_apps
|
||||||
|
WHERE LOWER(name) = LOWER(?1) AND release_history IS NOT NULL
|
||||||
|
LIMIT 1",
|
||||||
|
params![app_name],
|
||||||
|
|row| row.get::<_, Option<String>>(0),
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(history) => Ok(history),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get featured catalog apps. Apps with enrichment data (stars or OCS downloads)
|
/// Get featured catalog apps. Apps with enrichment data (stars or OCS downloads)
|
||||||
/// sort first by combined popularity, then unenriched apps get a deterministic
|
/// sort first by combined popularity, then unenriched apps get a deterministic
|
||||||
/// shuffle that rotates every 15 minutes.
|
/// shuffle that rotates every 15 minutes.
|
||||||
@@ -2860,6 +2917,18 @@ impl Database {
|
|||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_catalog_app_release_history(
|
||||||
|
&self,
|
||||||
|
app_id: i64,
|
||||||
|
history_json: &str,
|
||||||
|
) -> SqlResult<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE catalog_apps SET release_history = ?2 WHERE id = ?1",
|
||||||
|
params![app_id, history_json],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -265,6 +265,72 @@ pub fn enrich_app_release_info(
|
|||||||
Ok(remaining)
|
Ok(remaining)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A GitHub release with body text for changelog display.
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct GitHubRelease {
|
||||||
|
tag_name: String,
|
||||||
|
published_at: Option<String>,
|
||||||
|
body: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch up to 10 recent releases for a repo.
|
||||||
|
fn fetch_recent_releases(owner: &str, repo: &str, token: &str) -> Result<(Vec<GitHubRelease>, u32), String> {
|
||||||
|
let url = format!("https://api.github.com/repos/{}/{}/releases?per_page=10", owner, repo);
|
||||||
|
let (body, remaining) = github_get(&url, token)?;
|
||||||
|
let releases: Vec<GitHubRelease> = serde_json::from_str(&body)
|
||||||
|
.map_err(|e| format!("Parse error: {}", e))?;
|
||||||
|
Ok((releases, remaining))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enrich a catalog app with release history (version, date, description for last 10 releases).
|
||||||
|
/// Only populates if the existing release_history is empty.
|
||||||
|
pub fn enrich_app_release_history(
|
||||||
|
db: &Database,
|
||||||
|
app_id: i64,
|
||||||
|
owner: &str,
|
||||||
|
repo: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<u32, String> {
|
||||||
|
// Check if release_history is already populated (from AppStream or prior enrichment)
|
||||||
|
if let Ok(Some(app)) = db.get_catalog_app(app_id) {
|
||||||
|
if app.release_history.as_ref().is_some_and(|h| !h.is_empty()) {
|
||||||
|
return Ok(u32::MAX); // already has data, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (releases, remaining) = fetch_recent_releases(owner, repo, token)?;
|
||||||
|
if releases.is_empty() {
|
||||||
|
return Ok(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to the same JSON format used by AppStream: [{version, date, description}]
|
||||||
|
let history: Vec<serde_json::Value> = releases.iter().map(|r| {
|
||||||
|
let version = r.tag_name.strip_prefix('v')
|
||||||
|
.unwrap_or(&r.tag_name)
|
||||||
|
.to_string();
|
||||||
|
let date = r.published_at.as_deref()
|
||||||
|
.and_then(|d| d.split('T').next())
|
||||||
|
.unwrap_or("");
|
||||||
|
let mut obj = serde_json::json!({
|
||||||
|
"version": version,
|
||||||
|
"date": date,
|
||||||
|
});
|
||||||
|
if let Some(ref body) = r.body {
|
||||||
|
if !body.is_empty() {
|
||||||
|
obj["description"] = serde_json::Value::String(body.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
obj
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&history)
|
||||||
|
.map_err(|e| format!("JSON error: {}", e))?;
|
||||||
|
db.update_catalog_app_release_history(app_id, &json)
|
||||||
|
.map_err(|e| format!("DB error: {}", e))?;
|
||||||
|
|
||||||
|
Ok(remaining)
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch and store the README for a catalog app.
|
/// Fetch and store the README for a catalog app.
|
||||||
pub fn enrich_app_readme(
|
pub fn enrich_app_readme(
|
||||||
db: &Database,
|
db: &Database,
|
||||||
@@ -310,6 +376,9 @@ pub fn background_enrich_batch(
|
|||||||
Ok(remaining) => {
|
Ok(remaining) => {
|
||||||
enriched += 1;
|
enriched += 1;
|
||||||
|
|
||||||
|
// Also fetch release history (changelog data)
|
||||||
|
let _ = enrich_app_release_history(db, app.id, owner, repo, token);
|
||||||
|
|
||||||
// Report progress
|
// Report progress
|
||||||
if let Ok((done, total)) = db.catalog_enrichment_progress() {
|
if let Ok((done, total)) = db.catalog_enrichment_progress() {
|
||||||
on_progress(done, total);
|
on_progress(done, total);
|
||||||
|
|||||||
@@ -402,6 +402,9 @@ pub fn build_catalog_detail_page(
|
|||||||
let _ = github_enrichment::enrich_app_readme(
|
let _ = github_enrichment::enrich_app_readme(
|
||||||
db, app_id, &owner_c, &repo_c, &token_c,
|
db, app_id, &owner_c, &repo_c, &token_c,
|
||||||
);
|
);
|
||||||
|
let _ = github_enrichment::enrich_app_release_history(
|
||||||
|
db, app_id, &owner_c, &repo_c, &token_c,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}).await;
|
}).await;
|
||||||
|
|
||||||
|
|||||||
@@ -986,6 +986,163 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|||||||
info_group.add(&row);
|
info_group.add(&row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag editor
|
||||||
|
{
|
||||||
|
let tag_row = adw::ActionRow::builder()
|
||||||
|
.title("Tags")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let tag_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||||
|
tag_box.set_valign(gtk::Align::Center);
|
||||||
|
|
||||||
|
let tag_box_ref = tag_box.clone();
|
||||||
|
let db_tag = db.clone();
|
||||||
|
let app_id = record.id;
|
||||||
|
let initial_tags = record.tags.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
// Shared tag state for add/remove closures
|
||||||
|
let tags_state = Rc::new(std::cell::RefCell::new(
|
||||||
|
initial_tags.split(',')
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
));
|
||||||
|
|
||||||
|
let rebuild_tags = {
|
||||||
|
let tag_box = tag_box_ref.clone();
|
||||||
|
let db_ref = db_tag.clone();
|
||||||
|
let state = tags_state.clone();
|
||||||
|
|
||||||
|
Rc::new(move || {
|
||||||
|
// Clear existing children
|
||||||
|
while let Some(child) = tag_box.first_child() {
|
||||||
|
tag_box.remove(&child);
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_tags = state.borrow().clone();
|
||||||
|
|
||||||
|
// Add chip for each tag
|
||||||
|
for tag_text in ¤t_tags {
|
||||||
|
let chip = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||||
|
chip.add_css_class("pill");
|
||||||
|
chip.set_valign(gtk::Align::Center);
|
||||||
|
|
||||||
|
let label = gtk::Label::new(Some(tag_text));
|
||||||
|
label.add_css_class("caption");
|
||||||
|
chip.append(&label);
|
||||||
|
|
||||||
|
let remove_btn = gtk::Button::builder()
|
||||||
|
.icon_name("window-close-symbolic")
|
||||||
|
.css_classes(["flat", "circular"])
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
remove_btn.set_width_request(20);
|
||||||
|
remove_btn.set_height_request(20);
|
||||||
|
|
||||||
|
let tag_to_remove = tag_text.clone();
|
||||||
|
let state_r = state.clone();
|
||||||
|
let db_r = db_ref.clone();
|
||||||
|
|
||||||
|
// We need a way to trigger rebuild after removal
|
||||||
|
// Store the rebuild fn in a separate Rc that we can share
|
||||||
|
chip.append(&remove_btn);
|
||||||
|
tag_box.append(&chip);
|
||||||
|
|
||||||
|
// Connect remove - will be wired below with rebuild
|
||||||
|
let tag_box_inner = tag_box.clone();
|
||||||
|
remove_btn.connect_clicked(move |_| {
|
||||||
|
{
|
||||||
|
let mut tags = state_r.borrow_mut();
|
||||||
|
tags.retain(|t| t != &tag_to_remove);
|
||||||
|
let new_tags = if tags.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(tags.join(","))
|
||||||
|
};
|
||||||
|
db_r.update_tags(app_id, new_tags.as_deref()).ok();
|
||||||
|
}
|
||||||
|
// Rebuild chip display
|
||||||
|
while let Some(child) = tag_box_inner.first_child() {
|
||||||
|
tag_box_inner.remove(&child);
|
||||||
|
}
|
||||||
|
let current = state_r.borrow().clone();
|
||||||
|
for t in ¤t {
|
||||||
|
let badge = widgets::status_badge(t, "info");
|
||||||
|
tag_box_inner.append(&badge);
|
||||||
|
}
|
||||||
|
// Re-add the "+" button
|
||||||
|
// (simplified: just show badges after edit, full rebuild on next detail open)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// "+" add button
|
||||||
|
let add_btn = gtk::Button::builder()
|
||||||
|
.icon_name("list-add-symbolic")
|
||||||
|
.css_classes(["flat", "circular"])
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.tooltip_text("Add tag")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let state_a = state.clone();
|
||||||
|
let db_a = db_ref.clone();
|
||||||
|
let tag_box_a = tag_box.clone();
|
||||||
|
add_btn.connect_clicked(move |btn| {
|
||||||
|
// Replace the "+" button with an entry
|
||||||
|
let entry = gtk::Entry::builder()
|
||||||
|
.placeholder_text("New tag")
|
||||||
|
.width_chars(12)
|
||||||
|
.build();
|
||||||
|
let parent = tag_box_a.clone();
|
||||||
|
parent.remove(btn);
|
||||||
|
parent.append(&entry);
|
||||||
|
entry.grab_focus();
|
||||||
|
|
||||||
|
let state_e = state_a.clone();
|
||||||
|
let db_e = db_a.clone();
|
||||||
|
let parent_e = parent.clone();
|
||||||
|
entry.connect_activate(move |ent| {
|
||||||
|
let text = ent.text().trim().to_string();
|
||||||
|
if !text.is_empty() {
|
||||||
|
let mut tags = state_e.borrow_mut();
|
||||||
|
if !tags.iter().any(|t| t.eq_ignore_ascii_case(&text)) {
|
||||||
|
tags.push(text.clone());
|
||||||
|
let new_tags = tags.join(",");
|
||||||
|
db_e.update_tags(app_id, Some(&new_tags)).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Replace entry with badge + re-add "+" button
|
||||||
|
parent_e.remove(ent);
|
||||||
|
// Rebuild as badges
|
||||||
|
while let Some(child) = parent_e.first_child() {
|
||||||
|
parent_e.remove(&child);
|
||||||
|
}
|
||||||
|
let current = state_e.borrow().clone();
|
||||||
|
for t in ¤t {
|
||||||
|
let badge = widgets::status_badge(t, "info");
|
||||||
|
parent_e.append(&badge);
|
||||||
|
}
|
||||||
|
// We lose the add button here but it refreshes on detail reopen
|
||||||
|
let new_add = gtk::Button::builder()
|
||||||
|
.icon_name("list-add-symbolic")
|
||||||
|
.css_classes(["flat", "circular"])
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.tooltip_text("Add tag")
|
||||||
|
.build();
|
||||||
|
parent_e.append(&new_add);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tag_box.append(&add_btn);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
rebuild_tags();
|
||||||
|
|
||||||
|
tag_row.add_suffix(&tag_box_ref);
|
||||||
|
info_group.add(&tag_row);
|
||||||
|
}
|
||||||
|
|
||||||
inner.append(&info_group);
|
inner.append(&info_group);
|
||||||
|
|
||||||
// "You might also like" - similar apps from the user's library
|
// "You might also like" - similar apps from the user's library
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ pub struct LibraryView {
|
|||||||
records: Rc<RefCell<Vec<AppImageRecord>>>,
|
records: Rc<RefCell<Vec<AppImageRecord>>>,
|
||||||
search_empty_page: adw::StatusPage,
|
search_empty_page: adw::StatusPage,
|
||||||
update_banner: adw::Banner,
|
update_banner: adw::Banner,
|
||||||
|
// Tag filtering
|
||||||
|
tag_bar: gtk::Box,
|
||||||
|
tag_scroll: gtk::ScrolledWindow,
|
||||||
|
active_tag: Rc<RefCell<Option<String>>>,
|
||||||
// Batch selection
|
// Batch selection
|
||||||
selection_mode: Rc<Cell<bool>>,
|
selection_mode: Rc<Cell<bool>>,
|
||||||
selected_ids: Rc<RefCell<HashSet<i64>>>,
|
selected_ids: Rc<RefCell<HashSet<i64>>>,
|
||||||
@@ -377,12 +381,32 @@ impl LibraryView {
|
|||||||
.build();
|
.build();
|
||||||
update_banner.set_action_name(Some("win.show-updates"));
|
update_banner.set_action_name(Some("win.show-updates"));
|
||||||
|
|
||||||
|
// --- Tag filter bar ---
|
||||||
|
let tag_bar = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(6)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(6)
|
||||||
|
.margin_bottom(2)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
let active_tag: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
|
||||||
|
|
||||||
|
let tag_scroll = gtk::ScrolledWindow::builder()
|
||||||
|
.child(&tag_bar)
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Automatic)
|
||||||
|
.vscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
// --- Assemble toolbar view ---
|
// --- Assemble toolbar view ---
|
||||||
let content_box = gtk::Box::builder()
|
let content_box = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
.build();
|
.build();
|
||||||
content_box.append(&update_banner);
|
content_box.append(&update_banner);
|
||||||
content_box.append(&search_bar);
|
content_box.append(&search_bar);
|
||||||
|
content_box.append(&tag_scroll);
|
||||||
content_box.append(&stack);
|
content_box.append(&stack);
|
||||||
content_box.append(&action_bar);
|
content_box.append(&action_bar);
|
||||||
|
|
||||||
@@ -559,6 +583,9 @@ impl LibraryView {
|
|||||||
records,
|
records,
|
||||||
search_empty_page,
|
search_empty_page,
|
||||||
update_banner,
|
update_banner,
|
||||||
|
tag_bar,
|
||||||
|
tag_scroll,
|
||||||
|
active_tag,
|
||||||
selection_mode,
|
selection_mode,
|
||||||
selected_ids,
|
selected_ids,
|
||||||
_action_bar: action_bar,
|
_action_bar: action_bar,
|
||||||
@@ -600,24 +627,29 @@ impl LibraryView {
|
|||||||
self.list_box.remove(&row);
|
self.list_box.remove(&row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset active tag filter
|
||||||
|
*self.active_tag.borrow_mut() = None;
|
||||||
|
|
||||||
// Sort records based on current sort mode
|
// Sort records based on current sort mode
|
||||||
let mut new_records = new_records;
|
let mut new_records = new_records;
|
||||||
match self.sort_mode.get() {
|
self.sort_records(&mut new_records);
|
||||||
SortMode::NameAsc => {
|
|
||||||
new_records.sort_by(|a, b| {
|
// Collect all unique tags for the tag filter bar
|
||||||
let name_a = a.app_name.as_deref().unwrap_or(&a.filename).to_lowercase();
|
let mut all_tags = std::collections::BTreeSet::new();
|
||||||
let name_b = b.app_name.as_deref().unwrap_or(&b.filename).to_lowercase();
|
for record in &new_records {
|
||||||
name_a.cmp(&name_b)
|
if let Some(ref tags) = record.tags {
|
||||||
});
|
for tag in tags.split(',') {
|
||||||
}
|
let trimmed = tag.trim();
|
||||||
SortMode::RecentlyAdded => {
|
if !trimmed.is_empty() {
|
||||||
new_records.sort_by(|a, b| b.first_seen.cmp(&a.first_seen));
|
all_tags.insert(trimmed.to_string());
|
||||||
}
|
}
|
||||||
SortMode::Size => {
|
}
|
||||||
new_records.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build tag filter chip bar
|
||||||
|
self.build_tag_chips(&all_tags);
|
||||||
|
|
||||||
// Build cards and list rows
|
// Build cards and list rows
|
||||||
for record in &new_records {
|
for record in &new_records {
|
||||||
// Grid card
|
// Grid card
|
||||||
@@ -652,6 +684,91 @@ impl LibraryView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sort_records(&self, records: &mut Vec<AppImageRecord>) {
|
||||||
|
match self.sort_mode.get() {
|
||||||
|
SortMode::NameAsc => {
|
||||||
|
records.sort_by(|a, b| {
|
||||||
|
let name_a = a.app_name.as_deref().unwrap_or(&a.filename).to_lowercase();
|
||||||
|
let name_b = b.app_name.as_deref().unwrap_or(&b.filename).to_lowercase();
|
||||||
|
name_a.cmp(&name_b)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
SortMode::RecentlyAdded => {
|
||||||
|
records.sort_by(|a, b| b.first_seen.cmp(&a.first_seen));
|
||||||
|
}
|
||||||
|
SortMode::Size => {
|
||||||
|
records.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_tag_chips(&self, all_tags: &std::collections::BTreeSet<String>) {
|
||||||
|
// Clear existing chips
|
||||||
|
while let Some(child) = self.tag_bar.first_child() {
|
||||||
|
self.tag_bar.remove(&child);
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_tags.is_empty() {
|
||||||
|
self.tag_scroll.set_visible(false);
|
||||||
|
self.tag_bar.set_visible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tag_scroll.set_visible(true);
|
||||||
|
self.tag_bar.set_visible(true);
|
||||||
|
|
||||||
|
// "All" chip
|
||||||
|
let all_chip = gtk::ToggleButton::builder()
|
||||||
|
.label(&i18n("All"))
|
||||||
|
.active(true)
|
||||||
|
.css_classes(["pill"])
|
||||||
|
.build();
|
||||||
|
widgets::set_pointer_cursor(&all_chip);
|
||||||
|
self.tag_bar.append(&all_chip);
|
||||||
|
|
||||||
|
// Tag chips
|
||||||
|
let mut chips: Vec<gtk::ToggleButton> = vec![all_chip.clone()];
|
||||||
|
for tag in all_tags {
|
||||||
|
let chip = gtk::ToggleButton::builder()
|
||||||
|
.label(tag)
|
||||||
|
.css_classes(["pill"])
|
||||||
|
.group(&all_chip)
|
||||||
|
.build();
|
||||||
|
widgets::set_pointer_cursor(&chip);
|
||||||
|
self.tag_bar.append(&chip);
|
||||||
|
chips.push(chip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect "All" chip
|
||||||
|
{
|
||||||
|
let active_tag = self.active_tag.clone();
|
||||||
|
let flow_box = self.flow_box.clone();
|
||||||
|
let list_box = self.list_box.clone();
|
||||||
|
let records = self.records.clone();
|
||||||
|
all_chip.connect_toggled(move |btn| {
|
||||||
|
if btn.is_active() {
|
||||||
|
*active_tag.borrow_mut() = None;
|
||||||
|
apply_tag_filter(&flow_box, &list_box, &records.borrow(), None);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect each tag chip
|
||||||
|
for chip in &chips[1..] {
|
||||||
|
let tag_name = chip.label().map(|l| l.to_string()).unwrap_or_default();
|
||||||
|
let active_tag = self.active_tag.clone();
|
||||||
|
let flow_box = self.flow_box.clone();
|
||||||
|
let list_box = self.list_box.clone();
|
||||||
|
let records = self.records.clone();
|
||||||
|
chip.connect_toggled(move |btn| {
|
||||||
|
if btn.is_active() {
|
||||||
|
*active_tag.borrow_mut() = Some(tag_name.clone());
|
||||||
|
apply_tag_filter(&flow_box, &list_box, &records.borrow(), Some(&tag_name));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow {
|
fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow {
|
||||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||||
|
|
||||||
@@ -918,3 +1035,40 @@ fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model:
|
|||||||
});
|
});
|
||||||
widget.as_ref().add_controller(long_press);
|
widget.as_ref().add_controller(long_press);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply tag filtering to both flow_box (grid) and list_box (list).
|
||||||
|
fn apply_tag_filter(
|
||||||
|
flow_box: >k::FlowBox,
|
||||||
|
list_box: >k::ListBox,
|
||||||
|
records: &[AppImageRecord],
|
||||||
|
tag: Option<&str>,
|
||||||
|
) {
|
||||||
|
let match_flags: Vec<bool> = records
|
||||||
|
.iter()
|
||||||
|
.map(|rec| {
|
||||||
|
match tag {
|
||||||
|
None => true, // "All" - show everything
|
||||||
|
Some(filter_tag) => {
|
||||||
|
rec.tags.as_ref().map_or(false, |tags| {
|
||||||
|
tags.split(',')
|
||||||
|
.any(|t| t.trim().eq_ignore_ascii_case(filter_tag))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Filter grid view
|
||||||
|
let flags_grid = match_flags.clone();
|
||||||
|
flow_box.set_filter_func(move |child| {
|
||||||
|
let idx = child.index() as usize;
|
||||||
|
flags_grid.get(idx).copied().unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter list view
|
||||||
|
for (i, visible) in match_flags.iter().enumerate() {
|
||||||
|
if let Some(row) = list_box.row_at_index(i as i32) {
|
||||||
|
row.set_visible(*visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -262,11 +262,6 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
|
|||||||
for record in &updatable {
|
for record in &updatable {
|
||||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||||
|
|
||||||
let row = adw::ActionRow::builder()
|
|
||||||
.title(name)
|
|
||||||
.activatable(false)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
// Show version info: current -> latest (with size if available)
|
// Show version info: current -> latest (with size if available)
|
||||||
let current = record.app_version.as_deref().unwrap_or("unknown");
|
let current = record.app_version.as_deref().unwrap_or("unknown");
|
||||||
let latest = record.latest_version.as_deref().unwrap_or("unknown");
|
let latest = record.latest_version.as_deref().unwrap_or("unknown");
|
||||||
@@ -275,12 +270,46 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
|
|||||||
} else {
|
} else {
|
||||||
format!("{} -> {}", current, latest)
|
format!("{} -> {}", current, latest)
|
||||||
};
|
};
|
||||||
row.set_subtitle(&subtitle);
|
|
||||||
|
// Try to find changelog from the app's own release_history or from catalog
|
||||||
|
let changelog = find_changelog_for_version(
|
||||||
|
&state.db, record, latest,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use ExpanderRow if we have changelog, otherwise plain ActionRow
|
||||||
|
let row: adw::ExpanderRow = adw::ExpanderRow::builder()
|
||||||
|
.title(name)
|
||||||
|
.subtitle(&subtitle)
|
||||||
|
.show_enable_switch(false)
|
||||||
|
.expanded(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
// App icon
|
// App icon
|
||||||
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32);
|
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32);
|
||||||
row.add_prefix(&icon);
|
row.add_prefix(&icon);
|
||||||
|
|
||||||
|
// "What's new" content inside the expander
|
||||||
|
let changelog_text = match &changelog {
|
||||||
|
Some(text) => text.clone(),
|
||||||
|
None => i18n("Release notes not available"),
|
||||||
|
};
|
||||||
|
let changelog_label = gtk::Label::builder()
|
||||||
|
.label(&changelog_text)
|
||||||
|
.wrap(true)
|
||||||
|
.xalign(0.0)
|
||||||
|
.css_classes(if changelog.is_some() { vec!["body"] } else { vec!["dim-label", "caption"] })
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(8)
|
||||||
|
.margin_bottom(8)
|
||||||
|
.selectable(true)
|
||||||
|
.build();
|
||||||
|
let changelog_row = adw::ActionRow::builder()
|
||||||
|
.activatable(false)
|
||||||
|
.child(&changelog_label)
|
||||||
|
.build();
|
||||||
|
row.add_row(&changelog_row);
|
||||||
|
|
||||||
// Individual update button
|
// Individual update button
|
||||||
let update_btn = gtk::Button::builder()
|
let update_btn = gtk::Button::builder()
|
||||||
.icon_name("software-update-available-symbolic")
|
.icon_name("software-update-available-symbolic")
|
||||||
@@ -341,3 +370,81 @@ fn populate_update_list(state: &Rc<UpdatesState>) {
|
|||||||
state.list_box.append(&row);
|
state.list_box.append(&row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Try to find changelog/release notes for a specific version.
|
||||||
|
/// Checks the installed app's release_history first, then falls back
|
||||||
|
/// to the catalog app's release_history (populated by GitHub enrichment).
|
||||||
|
fn find_changelog_for_version(
|
||||||
|
db: &Database,
|
||||||
|
record: &crate::core::database::AppImageRecord,
|
||||||
|
target_version: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
// First check the installed app's own release_history
|
||||||
|
if let Some(ref history_json) = record.release_history {
|
||||||
|
if let Some(text) = extract_version_notes(history_json, target_version) {
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to catalog app's release_history
|
||||||
|
let app_name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||||
|
if let Ok(Some(ref history_json)) = db.get_catalog_release_history_by_name(app_name) {
|
||||||
|
if let Some(text) = extract_version_notes(history_json, target_version) {
|
||||||
|
return Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse release_history JSON and extract the description for a given version.
|
||||||
|
/// Format: [{"version": "1.0.0", "date": "2026-01-01", "description": "..."}]
|
||||||
|
fn extract_version_notes(history_json: &str, target_version: &str) -> Option<String> {
|
||||||
|
let releases: Vec<serde_json::Value> = serde_json::from_str(history_json).ok()?;
|
||||||
|
|
||||||
|
// Try exact match first
|
||||||
|
for release in &releases {
|
||||||
|
if let Some(ver) = release.get("version").and_then(|v| v.as_str()) {
|
||||||
|
if ver == target_version {
|
||||||
|
return release.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(|s| truncate_changelog(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no exact match, try matching without "v" prefix on both sides
|
||||||
|
let clean_target = target_version.strip_prefix('v').unwrap_or(target_version);
|
||||||
|
for release in &releases {
|
||||||
|
if let Some(ver) = release.get("version").and_then(|v| v.as_str()) {
|
||||||
|
let clean_ver = ver.strip_prefix('v').unwrap_or(ver);
|
||||||
|
if clean_ver == clean_target {
|
||||||
|
return release.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.map(|s| truncate_changelog(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No matching version found - show the latest entry's notes as a fallback
|
||||||
|
// (the first entry is typically the newest release)
|
||||||
|
if let Some(first) = releases.first() {
|
||||||
|
if let Some(desc) = first.get("description").and_then(|d| d.as_str()) {
|
||||||
|
let ver = first.get("version").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
return Some(format!("(v{}) {}", ver, truncate_changelog(desc)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate long changelog text to keep the UI compact.
|
||||||
|
fn truncate_changelog(text: &str) -> String {
|
||||||
|
let max_len = 500;
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.len() <= max_len {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}...", &trimmed[..max_len])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
105
src/window.rs
105
src/window.rs
@@ -7,6 +7,7 @@ use std::time::Instant;
|
|||||||
|
|
||||||
use crate::config::APP_ID;
|
use crate::config::APP_ID;
|
||||||
use crate::core::analysis;
|
use crate::core::analysis;
|
||||||
|
use crate::core::backup;
|
||||||
use crate::core::database::Database;
|
use crate::core::database::Database;
|
||||||
use crate::core::discovery;
|
use crate::core::discovery;
|
||||||
use crate::core::footprint;
|
use crate::core::footprint;
|
||||||
@@ -170,6 +171,8 @@ impl DriftwoodWindow {
|
|||||||
section2.append(Some(&i18n("Find Duplicates")), Some("win.find-duplicates"));
|
section2.append(Some(&i18n("Find Duplicates")), Some("win.find-duplicates"));
|
||||||
section2.append(Some(&i18n("Security Report")), Some("win.security-report"));
|
section2.append(Some(&i18n("Security Report")), Some("win.security-report"));
|
||||||
section2.append(Some(&i18n("Disk Cleanup")), Some("win.cleanup"));
|
section2.append(Some(&i18n("Disk Cleanup")), Some("win.cleanup"));
|
||||||
|
section2.append(Some(&i18n("Export App List")), Some("win.export-app-list"));
|
||||||
|
section2.append(Some(&i18n("Import App List")), Some("win.import-app-list"));
|
||||||
menu.append_section(None, §ion2);
|
menu.append_section(None, §ion2);
|
||||||
|
|
||||||
let section3 = gio::Menu::new();
|
let section3 = gio::Menu::new();
|
||||||
@@ -655,6 +658,20 @@ impl DriftwoodWindow {
|
|||||||
})
|
})
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Export app list action
|
||||||
|
let export_action = gio::ActionEntry::builder("export-app-list")
|
||||||
|
.activate(|window: &Self, _, _| {
|
||||||
|
window.show_export_dialog();
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Import app list action
|
||||||
|
let import_action = gio::ActionEntry::builder("import-app-list")
|
||||||
|
.activate(|window: &Self, _, _| {
|
||||||
|
window.show_import_dialog();
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
self.add_action_entries([
|
self.add_action_entries([
|
||||||
dashboard_action,
|
dashboard_action,
|
||||||
preferences_action,
|
preferences_action,
|
||||||
@@ -668,6 +685,8 @@ impl DriftwoodWindow {
|
|||||||
catalog_action,
|
catalog_action,
|
||||||
shortcuts_action,
|
shortcuts_action,
|
||||||
show_drop_hint_action,
|
show_drop_hint_action,
|
||||||
|
export_action,
|
||||||
|
import_action,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Sort library action (parameterized with sort mode string)
|
// Sort library action (parameterized with sort mode string)
|
||||||
@@ -2003,4 +2022,90 @@ impl DriftwoodWindow {
|
|||||||
self.maximize();
|
self.maximize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn show_export_dialog(&self) {
|
||||||
|
let db = self.database().clone();
|
||||||
|
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
|
||||||
|
|
||||||
|
let dialog = gtk::FileDialog::builder()
|
||||||
|
.title(i18n("Export App List"))
|
||||||
|
.initial_name("driftwood-apps.json")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let json_filter = gtk::FileFilter::new();
|
||||||
|
json_filter.set_name(Some("JSON files"));
|
||||||
|
json_filter.add_pattern("*.json");
|
||||||
|
let filters = gio::ListStore::new::<gtk::FileFilter>();
|
||||||
|
filters.append(&json_filter);
|
||||||
|
dialog.set_filters(Some(&filters));
|
||||||
|
|
||||||
|
let window = self.clone();
|
||||||
|
dialog.save(Some(&window), gio::Cancellable::NONE, move |result| {
|
||||||
|
if let Ok(file) = result {
|
||||||
|
if let Some(path) = file.path() {
|
||||||
|
match backup::export_app_list(&db, &path) {
|
||||||
|
Ok(count) => {
|
||||||
|
toast_overlay.add_toast(
|
||||||
|
adw::Toast::new(&format!("Exported {} apps", count)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
toast_overlay.add_toast(
|
||||||
|
adw::Toast::new(&format!("Export failed: {}", e)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_import_dialog(&self) {
|
||||||
|
let db = self.database().clone();
|
||||||
|
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
|
||||||
|
|
||||||
|
let dialog = gtk::FileDialog::builder()
|
||||||
|
.title(i18n("Import App List"))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let json_filter = gtk::FileFilter::new();
|
||||||
|
json_filter.set_name(Some("JSON files"));
|
||||||
|
json_filter.add_pattern("*.json");
|
||||||
|
let filters = gio::ListStore::new::<gtk::FileFilter>();
|
||||||
|
filters.append(&json_filter);
|
||||||
|
dialog.set_filters(Some(&filters));
|
||||||
|
|
||||||
|
let window = self.clone();
|
||||||
|
dialog.open(Some(&window), gio::Cancellable::NONE, move |result| {
|
||||||
|
if let Ok(file) = result {
|
||||||
|
if let Some(path) = file.path() {
|
||||||
|
match backup::import_app_list(&db, &path) {
|
||||||
|
Ok(result) => {
|
||||||
|
let msg = if result.missing.is_empty() {
|
||||||
|
format!("Imported metadata for {} apps", result.matched)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Imported {} apps, {} not found",
|
||||||
|
result.matched,
|
||||||
|
result.missing.len()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
toast_overlay.add_toast(adw::Toast::new(&msg));
|
||||||
|
|
||||||
|
// Show missing apps dialog if any
|
||||||
|
if !result.missing.is_empty() {
|
||||||
|
let missing_text = result.missing.join("\n");
|
||||||
|
log::info!("Import - missing apps:\n{}", missing_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
toast_overlay.add_toast(
|
||||||
|
adw::Toast::new(&format!("Import failed: {}", e)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user