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 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<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 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -407,6 +407,197 @@ fn read_manifest(archive_path: &Path) -> Result<BackupManifest, BackupError> {
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -154,6 +154,7 @@ pub struct CatalogApp {
|
||||
pub ocs_arch: Option<String>,
|
||||
pub ocs_md5sum: Option<String>,
|
||||
pub ocs_comments: Option<i64>,
|
||||
pub release_history: Option<String>,
|
||||
}
|
||||
|
||||
#[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<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<()> {
|
||||
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<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)
|
||||
/// 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)]
|
||||
|
||||
@@ -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<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.
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -986,6 +986,163 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> 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::<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);
|
||||
|
||||
// "You might also like" - similar apps from the user's library
|
||||
|
||||
@@ -46,6 +46,10 @@ pub struct LibraryView {
|
||||
records: Rc<RefCell<Vec<AppImageRecord>>>,
|
||||
search_empty_page: adw::StatusPage,
|
||||
update_banner: adw::Banner,
|
||||
// Tag filtering
|
||||
tag_bar: gtk::Box,
|
||||
tag_scroll: gtk::ScrolledWindow,
|
||||
active_tag: Rc<RefCell<Option<String>>>,
|
||||
// Batch selection
|
||||
selection_mode: Rc<Cell<bool>>,
|
||||
selected_ids: Rc<RefCell<HashSet<i64>>>,
|
||||
@@ -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<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 ---
|
||||
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<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 {
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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<UpdatesState>) {
|
||||
} 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<UpdatesState>) {
|
||||
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::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::<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