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:
lashman
2026-03-01 01:01:43 +02:00
parent 79519c500a
commit abb69dc753
9 changed files with 901 additions and 215 deletions

View File

@@ -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])
}
}