diff --git a/src/cli.rs b/src/cli.rs index f76313b..074b7ad 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,6 +3,7 @@ use glib::ExitCode; use gtk::prelude::*; use std::time::Instant; +use crate::core::backup; use crate::core::database::Database; use crate::core::discovery; use crate::core::duplicates; @@ -885,209 +886,39 @@ fn cmd_verify(path: &str, expected_sha256: Option<&str>) -> ExitCode { // --- Export/Import library --- fn cmd_export(db: &Database, output: Option<&str>) -> ExitCode { - let records = match db.get_all_appimages() { - Ok(r) => r, + let path = output.unwrap_or("driftwood-apps.json"); + 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) => { eprintln!("Error: {}", e); - return ExitCode::FAILURE; + ExitCode::FAILURE } - }; - - let appimages: Vec = 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 { - let content = match std::fs::read_to_string(file) { - Ok(c) => c, + let import_path = std::path::Path::new(file); + + 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) => { - eprintln!("Error reading {}: {}", file, e); - return ExitCode::FAILURE; + eprintln!("Error: {}", e); + 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 } diff --git a/src/core/backup.rs b/src/core/backup.rs index e682e7b..a554f59 100644 --- a/src/core/backup.rs +++ b/src/core/backup.rs @@ -407,6 +407,197 @@ fn read_manifest(archive_path: &Path) -> Result { .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, +} + +/// Export the app list to a JSON file (v2 format with extended fields). +pub fn export_app_list(db: &Database, path: &Path) -> Result { + let records = db.get_all_appimages() + .map_err(|e| BackupError::Database(e.to_string()))?; + + let appimages: Vec = 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 { + 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 = 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::>().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 { + 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)] mod tests { use super::*; diff --git a/src/core/database.rs b/src/core/database.rs index aa3c11f..e942e6c 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -154,6 +154,7 @@ pub struct CatalogApp { pub ocs_arch: Option, pub ocs_md5sum: Option, pub ocs_comments: Option, + pub release_history: Option, } #[derive(Debug, Clone)] @@ -484,6 +485,10 @@ impl Database { self.migrate_to_v18()?; } + if current_version < 19 { + self.migrate_to_v19()?; + } + // Ensure all expected columns exist (repairs DBs where a migration // was updated after it had already run on this database) self.ensure_columns()?; @@ -1050,6 +1055,18 @@ impl Database { 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( &self, path: &str, @@ -1221,7 +1238,8 @@ impl Database { 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"; + 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. /// 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_md5sum: row.get(36).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(()) } + /// Get all distinct tags used across all installed apps. + /// Returns a sorted, deduplicated list of tag strings. + pub fn get_all_tags(&self) -> SqlResult> { + 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<()> { self.conn.execute( "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> { + 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>(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) /// sort first by combined popularity, then unenriched apps get a deterministic /// shuffle that rotates every 15 minutes. @@ -2860,6 +2917,18 @@ impl Database { )?; 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)] diff --git a/src/core/github_enrichment.rs b/src/core/github_enrichment.rs index 16270ee..af39b08 100644 --- a/src/core/github_enrichment.rs +++ b/src/core/github_enrichment.rs @@ -265,6 +265,72 @@ pub fn enrich_app_release_info( Ok(remaining) } +/// A GitHub release with body text for changelog display. +#[derive(Debug, serde::Deserialize)] +struct GitHubRelease { + tag_name: String, + published_at: Option, + body: Option, +} + +/// Fetch up to 10 recent releases for a repo. +fn fetch_recent_releases(owner: &str, repo: &str, token: &str) -> Result<(Vec, 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 = 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 { + // 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 = 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. pub fn enrich_app_readme( db: &Database, @@ -310,6 +376,9 @@ pub fn background_enrich_batch( Ok(remaining) => { enriched += 1; + // Also fetch release history (changelog data) + let _ = enrich_app_release_history(db, app.id, owner, repo, token); + // Report progress if let Ok((done, total)) = db.catalog_enrichment_progress() { on_progress(done, total); diff --git a/src/ui/catalog_detail.rs b/src/ui/catalog_detail.rs index 56c1ef9..a78ac23 100644 --- a/src/ui/catalog_detail.rs +++ b/src/ui/catalog_detail.rs @@ -402,6 +402,9 @@ pub fn build_catalog_detail_page( let _ = github_enrichment::enrich_app_readme( 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; diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index ec696f9..6d04792 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -986,6 +986,163 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { 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::>() + )); + + 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); // "You might also like" - similar apps from the user's library diff --git a/src/ui/library_view.rs b/src/ui/library_view.rs index b651ba9..bbe3935 100644 --- a/src/ui/library_view.rs +++ b/src/ui/library_view.rs @@ -46,6 +46,10 @@ pub struct LibraryView { records: Rc>>, search_empty_page: adw::StatusPage, update_banner: adw::Banner, + // Tag filtering + tag_bar: gtk::Box, + tag_scroll: gtk::ScrolledWindow, + active_tag: Rc>>, // Batch selection selection_mode: Rc>, selected_ids: Rc>>, @@ -377,12 +381,32 @@ impl LibraryView { .build(); 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>> = 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 --- let content_box = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .build(); content_box.append(&update_banner); content_box.append(&search_bar); + content_box.append(&tag_scroll); content_box.append(&stack); content_box.append(&action_bar); @@ -559,6 +583,9 @@ impl LibraryView { records, search_empty_page, update_banner, + tag_bar, + tag_scroll, + active_tag, selection_mode, selected_ids, _action_bar: action_bar, @@ -600,24 +627,29 @@ impl LibraryView { self.list_box.remove(&row); } + // Reset active tag filter + *self.active_tag.borrow_mut() = None; + // Sort records based on current sort mode let mut new_records = new_records; - match self.sort_mode.get() { - SortMode::NameAsc => { - new_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 => { - new_records.sort_by(|a, b| b.first_seen.cmp(&a.first_seen)); - } - SortMode::Size => { - new_records.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes)); + self.sort_records(&mut new_records); + + // Collect all unique tags for the tag filter bar + let mut all_tags = std::collections::BTreeSet::new(); + for record in &new_records { + if let Some(ref tags) = record.tags { + for tag in tags.split(',') { + let trimmed = tag.trim(); + if !trimmed.is_empty() { + all_tags.insert(trimmed.to_string()); + } + } } } + // Build tag filter chip bar + self.build_tag_chips(&all_tags); + // Build cards and list rows for record in &new_records { // Grid card @@ -652,6 +684,91 @@ impl LibraryView { } } + fn sort_records(&self, records: &mut Vec) { + 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) { + // 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 = 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 { let name = record.app_name.as_deref().unwrap_or(&record.filename); @@ -918,3 +1035,40 @@ fn attach_context_menu(widget: &impl gtk::prelude::IsA, menu_model: }); 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 = 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); + } + } +} diff --git a/src/ui/updates_view.rs b/src/ui/updates_view.rs index ad5f887..71222a8 100644 --- a/src/ui/updates_view.rs +++ b/src/ui/updates_view.rs @@ -262,11 +262,6 @@ fn populate_update_list(state: &Rc) { for record in &updatable { 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) let current = record.app_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) { } else { 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 let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32); 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 let update_btn = gtk::Button::builder() .icon_name("software-update-available-symbolic") @@ -341,3 +370,81 @@ fn populate_update_list(state: &Rc) { 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 { + // 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 { + let releases: Vec = 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]) + } +} diff --git a/src/window.rs b/src/window.rs index ab25fe3..e84c3a1 100644 --- a/src/window.rs +++ b/src/window.rs @@ -7,6 +7,7 @@ use std::time::Instant; use crate::config::APP_ID; use crate::core::analysis; +use crate::core::backup; use crate::core::database::Database; use crate::core::discovery; use crate::core::footprint; @@ -170,6 +171,8 @@ impl DriftwoodWindow { section2.append(Some(&i18n("Find Duplicates")), Some("win.find-duplicates")); section2.append(Some(&i18n("Security Report")), Some("win.security-report")); 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); let section3 = gio::Menu::new(); @@ -655,6 +658,20 @@ impl DriftwoodWindow { }) .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([ dashboard_action, preferences_action, @@ -668,6 +685,8 @@ impl DriftwoodWindow { catalog_action, shortcuts_action, show_drop_hint_action, + export_action, + import_action, ]); // Sort library action (parameterized with sort mode string) @@ -2003,4 +2022,90 @@ impl DriftwoodWindow { 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::(); + 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::(); + 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)), + ); + } + } + } + } + }); + } }