- Make detail view banner scroll with content instead of staying fixed, preventing tall banners from eating screen space - Optimize squashfs offset scanning with buffered 256KB chunk reading instead of loading entire file into memory (critical for 1.5GB+ files) - Add screenshot URL parsing from AppStream XML and async image display with carousel in the overview tab - Fix infinite re-analysis bug: has_appstream check caused every app without AppStream data to be re-analyzed on every startup. Now handled via one-time migration reset in v10 - Database migration v10: add screenshot_urls column, reset analysis status for one-time re-scan with new parser
743 lines
27 KiB
Rust
743 lines
27 KiB
Rust
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use quick_xml::events::Event;
|
|
use quick_xml::Reader;
|
|
|
|
use super::database::Database;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AppStream metainfo XML parser - reads metadata FROM AppImages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct AppStreamMetadata {
|
|
pub id: Option<String>,
|
|
pub name: Option<String>,
|
|
pub summary: Option<String>,
|
|
pub description: Option<String>,
|
|
pub developer: Option<String>,
|
|
pub project_license: Option<String>,
|
|
pub project_group: Option<String>,
|
|
pub urls: HashMap<String, String>,
|
|
pub keywords: Vec<String>,
|
|
pub categories: Vec<String>,
|
|
pub content_rating_summary: Option<String>,
|
|
pub releases: Vec<ReleaseInfo>,
|
|
pub mime_types: Vec<String>,
|
|
pub screenshot_urls: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ReleaseInfo {
|
|
pub version: String,
|
|
pub date: Option<String>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
/// Parse an AppStream metainfo XML file and extract all metadata.
|
|
pub fn parse_appstream_file(path: &Path) -> Option<AppStreamMetadata> {
|
|
let content = std::fs::read_to_string(path).ok()?;
|
|
parse_appstream_xml(&content)
|
|
}
|
|
|
|
/// Parse AppStream XML from a string.
|
|
pub fn parse_appstream_xml(xml: &str) -> Option<AppStreamMetadata> {
|
|
let mut reader = Reader::from_str(xml);
|
|
let mut meta = AppStreamMetadata::default();
|
|
let mut buf = Vec::new();
|
|
|
|
let mut current_tag = String::new();
|
|
let mut in_component = false;
|
|
let mut in_description = false;
|
|
let mut in_release = false;
|
|
let mut in_release_description = false;
|
|
let mut in_provides = false;
|
|
let mut in_keywords = false;
|
|
let mut in_categories = false;
|
|
let mut description_parts: Vec<String> = Vec::new();
|
|
let mut release_desc_parts: Vec<String> = Vec::new();
|
|
let mut current_url_type = String::new();
|
|
let mut current_release_version = String::new();
|
|
let mut current_release_date = String::new();
|
|
let mut content_rating_attrs: Vec<(String, String)> = Vec::new();
|
|
let mut in_content_rating = false;
|
|
let mut current_content_attr_id = String::new();
|
|
let mut in_developer = false;
|
|
let mut in_screenshots = false;
|
|
let mut in_screenshot_image = false;
|
|
let mut depth = 0u32;
|
|
let mut description_depth = 0u32;
|
|
let mut release_desc_depth = 0u32;
|
|
|
|
loop {
|
|
match reader.read_event_into(&mut buf) {
|
|
Ok(Event::Start(ref e)) => {
|
|
let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
|
depth += 1;
|
|
|
|
match tag_name.as_str() {
|
|
"component" => {
|
|
in_component = true;
|
|
}
|
|
"description" if in_component && !in_release => {
|
|
in_description = true;
|
|
description_depth = depth;
|
|
description_parts.clear();
|
|
}
|
|
"description" if in_release => {
|
|
in_release_description = true;
|
|
release_desc_depth = depth;
|
|
release_desc_parts.clear();
|
|
}
|
|
"p" if in_description && !in_release_description => {
|
|
current_tag = "description_p".to_string();
|
|
}
|
|
"li" if in_description && !in_release_description => {
|
|
current_tag = "description_li".to_string();
|
|
}
|
|
"p" if in_release_description => {
|
|
current_tag = "release_desc_p".to_string();
|
|
}
|
|
"li" if in_release_description => {
|
|
current_tag = "release_desc_li".to_string();
|
|
}
|
|
"url" if in_component => {
|
|
current_url_type = String::new();
|
|
for attr in e.attributes().flatten() {
|
|
if attr.key.as_ref() == b"type" {
|
|
current_url_type =
|
|
String::from_utf8_lossy(&attr.value).to_string();
|
|
}
|
|
}
|
|
current_tag = "url".to_string();
|
|
}
|
|
"release" if in_component => {
|
|
in_release = true;
|
|
current_release_version.clear();
|
|
current_release_date.clear();
|
|
release_desc_parts.clear();
|
|
for attr in e.attributes().flatten() {
|
|
match attr.key.as_ref() {
|
|
b"version" => {
|
|
current_release_version =
|
|
String::from_utf8_lossy(&attr.value).to_string();
|
|
}
|
|
b"date" => {
|
|
current_release_date =
|
|
String::from_utf8_lossy(&attr.value).to_string();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
"content_rating" if in_component => {
|
|
in_content_rating = true;
|
|
content_rating_attrs.clear();
|
|
}
|
|
"content_attribute" if in_content_rating => {
|
|
current_content_attr_id.clear();
|
|
for attr in e.attributes().flatten() {
|
|
if attr.key.as_ref() == b"id" {
|
|
current_content_attr_id =
|
|
String::from_utf8_lossy(&attr.value).to_string();
|
|
}
|
|
}
|
|
current_tag = "content_attribute".to_string();
|
|
}
|
|
"provides" if in_component => {
|
|
in_provides = true;
|
|
}
|
|
"mediatype" if in_provides => {
|
|
current_tag = "mediatype".to_string();
|
|
}
|
|
"keywords" if in_component => {
|
|
in_keywords = true;
|
|
}
|
|
"keyword" if in_keywords => {
|
|
current_tag = "keyword".to_string();
|
|
}
|
|
"categories" if in_component => {
|
|
in_categories = true;
|
|
}
|
|
"category" if in_categories => {
|
|
current_tag = "category".to_string();
|
|
}
|
|
"screenshots" if in_component => {
|
|
in_screenshots = true;
|
|
}
|
|
"image" if in_screenshots => {
|
|
// Prefer "source" type, but accept any <image>
|
|
in_screenshot_image = true;
|
|
current_tag = "screenshot_image".to_string();
|
|
}
|
|
"developer" if in_component => {
|
|
in_developer = true;
|
|
}
|
|
"developer_name" if in_component && !in_developer => {
|
|
current_tag = "developer_name".to_string();
|
|
}
|
|
"name" if in_developer => {
|
|
current_tag = "developer_child_name".to_string();
|
|
}
|
|
"id" if in_component && depth == 2 => {
|
|
current_tag = "id".to_string();
|
|
}
|
|
"name" if in_component && !in_developer && depth == 2 => {
|
|
let has_lang = e.attributes().flatten().any(|a| {
|
|
a.key.as_ref() == b"xml:lang" || a.key.as_ref() == b"lang"
|
|
});
|
|
if !has_lang {
|
|
current_tag = "name".to_string();
|
|
}
|
|
}
|
|
"summary" if in_component && depth == 2 => {
|
|
let has_lang = e.attributes().flatten().any(|a| {
|
|
a.key.as_ref() == b"xml:lang" || a.key.as_ref() == b"lang"
|
|
});
|
|
if !has_lang {
|
|
current_tag = "summary".to_string();
|
|
}
|
|
}
|
|
"project_license" if in_component => {
|
|
current_tag = "project_license".to_string();
|
|
}
|
|
"project_group" if in_component => {
|
|
current_tag = "project_group".to_string();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(Event::End(ref e)) => {
|
|
let tag_name = String::from_utf8_lossy(e.name().as_ref()).to_string();
|
|
|
|
match tag_name.as_str() {
|
|
"component" => {
|
|
in_component = false;
|
|
}
|
|
"description"
|
|
if in_release_description && depth == release_desc_depth =>
|
|
{
|
|
in_release_description = false;
|
|
}
|
|
"description" if in_description && depth == description_depth => {
|
|
in_description = false;
|
|
if !description_parts.is_empty() {
|
|
meta.description = Some(description_parts.join("\n\n"));
|
|
}
|
|
}
|
|
"release" => {
|
|
if !current_release_version.is_empty()
|
|
&& meta.releases.len() < 10
|
|
{
|
|
let desc = if release_desc_parts.is_empty() {
|
|
None
|
|
} else {
|
|
Some(release_desc_parts.join("\n"))
|
|
};
|
|
meta.releases.push(ReleaseInfo {
|
|
version: current_release_version.clone(),
|
|
date: if current_release_date.is_empty() {
|
|
None
|
|
} else {
|
|
Some(current_release_date.clone())
|
|
},
|
|
description: desc,
|
|
});
|
|
}
|
|
in_release = false;
|
|
in_release_description = false;
|
|
}
|
|
"content_rating" => {
|
|
in_content_rating = false;
|
|
meta.content_rating_summary =
|
|
Some(summarize_content_rating(&content_rating_attrs));
|
|
}
|
|
"provides" => {
|
|
in_provides = false;
|
|
}
|
|
"keywords" => {
|
|
in_keywords = false;
|
|
}
|
|
"categories" => {
|
|
in_categories = false;
|
|
}
|
|
"screenshots" => {
|
|
in_screenshots = false;
|
|
}
|
|
"image" if in_screenshot_image => {
|
|
in_screenshot_image = false;
|
|
}
|
|
"developer" => {
|
|
in_developer = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
current_tag.clear();
|
|
depth = depth.saturating_sub(1);
|
|
}
|
|
Ok(Event::Text(ref e)) => {
|
|
let text = e.unescape().unwrap_or_default().trim().to_string();
|
|
if text.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
match current_tag.as_str() {
|
|
"id" => meta.id = Some(text),
|
|
"name" => meta.name = Some(text),
|
|
"summary" => meta.summary = Some(text),
|
|
"project_license" => meta.project_license = Some(text),
|
|
"project_group" => meta.project_group = Some(text),
|
|
"url" if !current_url_type.is_empty() => {
|
|
meta.urls.insert(current_url_type.clone(), text);
|
|
}
|
|
"description_p" => {
|
|
description_parts.push(text);
|
|
}
|
|
"description_li" => {
|
|
description_parts.push(format!(" - {}", text));
|
|
}
|
|
"release_desc_p" => {
|
|
release_desc_parts.push(text);
|
|
}
|
|
"release_desc_li" => {
|
|
release_desc_parts.push(format!(" - {}", text));
|
|
}
|
|
"content_attribute" if !current_content_attr_id.is_empty() => {
|
|
content_rating_attrs
|
|
.push((current_content_attr_id.clone(), text));
|
|
}
|
|
"mediatype" => {
|
|
meta.mime_types.push(text);
|
|
}
|
|
"keyword" => {
|
|
meta.keywords.push(text);
|
|
}
|
|
"category" => {
|
|
meta.categories.push(text);
|
|
}
|
|
"screenshot_image" => {
|
|
if text.starts_with("http") && meta.screenshot_urls.len() < 10 {
|
|
meta.screenshot_urls.push(text);
|
|
}
|
|
}
|
|
"developer_name" => {
|
|
if meta.developer.is_none() {
|
|
meta.developer = Some(text);
|
|
}
|
|
}
|
|
"developer_child_name" => {
|
|
meta.developer = Some(text);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(Event::Empty(ref e)) => {
|
|
let tag_name =
|
|
String::from_utf8_lossy(e.name().as_ref()).to_string();
|
|
if tag_name == "release" && in_component {
|
|
let mut ver = String::new();
|
|
let mut date = String::new();
|
|
for attr in e.attributes().flatten() {
|
|
match attr.key.as_ref() {
|
|
b"version" => {
|
|
ver = String::from_utf8_lossy(&attr.value)
|
|
.to_string();
|
|
}
|
|
b"date" => {
|
|
date = String::from_utf8_lossy(&attr.value)
|
|
.to_string();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if !ver.is_empty() && meta.releases.len() < 10 {
|
|
meta.releases.push(ReleaseInfo {
|
|
version: ver,
|
|
date: if date.is_empty() { None } else { Some(date) },
|
|
description: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(Event::Eof) => break,
|
|
Err(e) => {
|
|
log::warn!("AppStream XML parse error: {}", e);
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
buf.clear();
|
|
}
|
|
|
|
if meta.id.is_some() || meta.name.is_some() || meta.summary.is_some() {
|
|
Some(meta)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Summarize OARS content rating attributes into a human-readable level.
|
|
fn summarize_content_rating(attrs: &[(String, String)]) -> String {
|
|
let max_level = attrs
|
|
.iter()
|
|
.map(|(_, v)| match v.as_str() {
|
|
"intense" => 3,
|
|
"moderate" => 2,
|
|
"mild" => 1,
|
|
_ => 0,
|
|
})
|
|
.max()
|
|
.unwrap_or(0);
|
|
|
|
match max_level {
|
|
0 => "All ages".to_string(),
|
|
1 => "Mild content".to_string(),
|
|
2 => "Moderate content".to_string(),
|
|
3 => "Mature content".to_string(),
|
|
_ => "Unknown".to_string(),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AppStream catalog generation - writes catalog XML for GNOME Software/Discover
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Generate an AppStream catalog XML from the Driftwood database.
|
|
/// This allows GNOME Software / KDE Discover to see locally managed AppImages.
|
|
pub fn generate_catalog(db: &Database) -> Result<String, AppStreamError> {
|
|
let records = db.get_all_appimages()
|
|
.map_err(|e| AppStreamError::Database(e.to_string()))?;
|
|
|
|
let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
|
xml.push_str("<components version=\"0.16\" origin=\"driftwood\">\n");
|
|
|
|
for record in &records {
|
|
let app_name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
let app_id = make_component_id(app_name);
|
|
let description = record.description.as_deref().unwrap_or("");
|
|
|
|
xml.push_str(" <component type=\"desktop-application\">\n");
|
|
xml.push_str(&format!(" <id>appimage.{}</id>\n", xml_escape(&app_id)));
|
|
xml.push_str(&format!(" <name>{}</name>\n", xml_escape(app_name)));
|
|
|
|
if !description.is_empty() {
|
|
xml.push_str(&format!(" <summary>{}</summary>\n", xml_escape(description)));
|
|
}
|
|
|
|
xml.push_str(&format!(" <pkgname>{}</pkgname>\n", xml_escape(&record.filename)));
|
|
|
|
if let Some(version) = &record.app_version {
|
|
xml.push_str(" <releases>\n");
|
|
xml.push_str(&format!(
|
|
" <release version=\"{}\" />\n",
|
|
xml_escape(version),
|
|
));
|
|
xml.push_str(" </releases>\n");
|
|
}
|
|
|
|
if let Some(categories) = &record.categories {
|
|
xml.push_str(" <categories>\n");
|
|
for cat in categories.split(';').filter(|c| !c.is_empty()) {
|
|
xml.push_str(&format!(" <category>{}</category>\n", xml_escape(cat.trim())));
|
|
}
|
|
xml.push_str(" </categories>\n");
|
|
}
|
|
|
|
// Provide hint about source
|
|
xml.push_str(" <metadata>\n");
|
|
xml.push_str(" <value key=\"managed-by\">driftwood</value>\n");
|
|
xml.push_str(&format!(
|
|
" <value key=\"appimage-path\">{}</value>\n",
|
|
xml_escape(&record.path),
|
|
));
|
|
xml.push_str(" </metadata>\n");
|
|
|
|
xml.push_str(" </component>\n");
|
|
}
|
|
|
|
xml.push_str("</components>\n");
|
|
Ok(xml)
|
|
}
|
|
|
|
/// Install the AppStream catalog to the local swcatalog directory.
|
|
/// GNOME Software reads from `~/.local/share/swcatalog/xml/`.
|
|
pub fn install_catalog(db: &Database) -> Result<PathBuf, AppStreamError> {
|
|
let catalog_xml = generate_catalog(db)?;
|
|
|
|
let catalog_dir = dirs::data_dir()
|
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
|
.join("swcatalog")
|
|
.join("xml");
|
|
|
|
fs::create_dir_all(&catalog_dir)
|
|
.map_err(|e| AppStreamError::Io(e.to_string()))?;
|
|
|
|
let catalog_path = catalog_dir.join("driftwood.xml");
|
|
fs::write(&catalog_path, &catalog_xml)
|
|
.map_err(|e| AppStreamError::Io(e.to_string()))?;
|
|
|
|
Ok(catalog_path)
|
|
}
|
|
|
|
/// Remove the AppStream catalog from the local swcatalog directory.
|
|
pub fn uninstall_catalog() -> Result<(), AppStreamError> {
|
|
let catalog_path = dirs::data_dir()
|
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
|
.join("swcatalog")
|
|
.join("xml")
|
|
.join("driftwood.xml");
|
|
|
|
if catalog_path.exists() {
|
|
fs::remove_file(&catalog_path)
|
|
.map_err(|e| AppStreamError::Io(e.to_string()))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if the AppStream catalog is currently installed.
|
|
pub fn is_catalog_installed() -> bool {
|
|
let catalog_path = dirs::data_dir()
|
|
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
|
.join("swcatalog")
|
|
.join("xml")
|
|
.join("driftwood.xml");
|
|
|
|
catalog_path.exists()
|
|
}
|
|
|
|
// --- Utility functions ---
|
|
|
|
fn make_component_id(name: &str) -> String {
|
|
name.chars()
|
|
.map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c.to_ascii_lowercase() } else { '_' })
|
|
.collect::<String>()
|
|
.trim_matches('_')
|
|
.to_string()
|
|
}
|
|
|
|
fn xml_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
// --- Error types ---
|
|
|
|
#[derive(Debug)]
|
|
pub enum AppStreamError {
|
|
Database(String),
|
|
Io(String),
|
|
}
|
|
|
|
impl std::fmt::Display for AppStreamError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Database(e) => write!(f, "Database error: {}", e),
|
|
Self::Io(e) => write!(f, "I/O error: {}", e),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_make_component_id() {
|
|
assert_eq!(make_component_id("Firefox"), "firefox");
|
|
assert_eq!(make_component_id("My App 2.0"), "my_app_2.0");
|
|
assert_eq!(make_component_id("GIMP"), "gimp");
|
|
}
|
|
|
|
#[test]
|
|
fn test_xml_escape() {
|
|
assert_eq!(xml_escape("hello & world"), "hello & world");
|
|
assert_eq!(xml_escape("<tag>"), "<tag>");
|
|
assert_eq!(xml_escape("it's \"quoted\""), "it's "quoted"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_catalog_empty() {
|
|
let db = crate::core::database::Database::open_in_memory().unwrap();
|
|
let xml = generate_catalog(&db).unwrap();
|
|
assert!(xml.contains("<components"));
|
|
assert!(xml.contains("</components>"));
|
|
// No individual component entries in an empty DB
|
|
assert!(!xml.contains("<component "));
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_catalog_with_app() {
|
|
let db = crate::core::database::Database::open_in_memory().unwrap();
|
|
db.upsert_appimage(
|
|
"/tmp/test.AppImage",
|
|
"test.AppImage",
|
|
Some(2),
|
|
1024,
|
|
true,
|
|
None,
|
|
).unwrap();
|
|
db.update_metadata(
|
|
1,
|
|
Some("TestApp"),
|
|
Some("1.0"),
|
|
None,
|
|
None,
|
|
Some("Utility;"),
|
|
None,
|
|
None,
|
|
None,
|
|
).ok();
|
|
|
|
let xml = generate_catalog(&db).unwrap();
|
|
assert!(xml.contains("appimage.testapp"));
|
|
assert!(xml.contains("<pkgname>test.AppImage</pkgname>"));
|
|
assert!(xml.contains("managed-by"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_appstream_error_display() {
|
|
let err = AppStreamError::Database("db error".to_string());
|
|
assert!(format!("{}", err).contains("db error"));
|
|
let err = AppStreamError::Io("write failed".to_string());
|
|
assert!(format!("{}", err).contains("write failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_minimal_appstream() {
|
|
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<component type="desktop-application">
|
|
<id>org.example.TestApp</id>
|
|
<name>Test App</name>
|
|
<summary>A test application</summary>
|
|
<metadata_license>CC0-1.0</metadata_license>
|
|
<project_license>MIT</project_license>
|
|
</component>"#;
|
|
let meta = parse_appstream_xml(xml).unwrap();
|
|
assert_eq!(meta.id.as_deref(), Some("org.example.TestApp"));
|
|
assert_eq!(meta.name.as_deref(), Some("Test App"));
|
|
assert_eq!(meta.summary.as_deref(), Some("A test application"));
|
|
assert_eq!(meta.project_license.as_deref(), Some("MIT"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_full_appstream() {
|
|
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<component type="desktop-application">
|
|
<id>org.kde.krita</id>
|
|
<name>Krita</name>
|
|
<name xml:lang="de">Krita</name>
|
|
<summary>Digital Painting, Creative Freedom</summary>
|
|
<developer>
|
|
<name>KDE Community</name>
|
|
</developer>
|
|
<project_license>GPL-3.0-or-later</project_license>
|
|
<project_group>KDE</project_group>
|
|
<description>
|
|
<p>Krita is a creative painting application.</p>
|
|
<p>Features include:</p>
|
|
<ul>
|
|
<li>Layer support</li>
|
|
<li>Brush engines</li>
|
|
</ul>
|
|
</description>
|
|
<url type="homepage">https://krita.org</url>
|
|
<url type="bugtracker">https://bugs.kde.org</url>
|
|
<url type="donation">https://krita.org/support-us/donations</url>
|
|
<keywords>
|
|
<keyword>paint</keyword>
|
|
<keyword>draw</keyword>
|
|
</keywords>
|
|
<content_rating type="oars-1.1">
|
|
<content_attribute id="violence-cartoon">none</content_attribute>
|
|
<content_attribute id="language-humor">mild</content_attribute>
|
|
</content_rating>
|
|
<releases>
|
|
<release version="5.2.0" date="2024-01-15">
|
|
<description>
|
|
<p>Major update with new features.</p>
|
|
</description>
|
|
</release>
|
|
<release version="5.1.0" date="2023-09-01"/>
|
|
</releases>
|
|
<provides>
|
|
<mediatype>image/png</mediatype>
|
|
<mediatype>image/jpeg</mediatype>
|
|
</provides>
|
|
</component>"#;
|
|
let meta = parse_appstream_xml(xml).unwrap();
|
|
assert_eq!(meta.id.as_deref(), Some("org.kde.krita"));
|
|
assert_eq!(meta.name.as_deref(), Some("Krita"));
|
|
assert_eq!(meta.developer.as_deref(), Some("KDE Community"));
|
|
assert_eq!(meta.project_license.as_deref(), Some("GPL-3.0-or-later"));
|
|
assert_eq!(meta.project_group.as_deref(), Some("KDE"));
|
|
assert_eq!(
|
|
meta.urls.get("homepage").map(|s| s.as_str()),
|
|
Some("https://krita.org")
|
|
);
|
|
assert_eq!(
|
|
meta.urls.get("bugtracker").map(|s| s.as_str()),
|
|
Some("https://bugs.kde.org")
|
|
);
|
|
assert_eq!(meta.keywords, vec!["paint", "draw"]);
|
|
assert_eq!(
|
|
meta.content_rating_summary.as_deref(),
|
|
Some("Mild content")
|
|
);
|
|
assert_eq!(meta.releases.len(), 2);
|
|
assert_eq!(meta.releases[0].version, "5.2.0");
|
|
assert_eq!(meta.releases[0].date.as_deref(), Some("2024-01-15"));
|
|
assert!(meta.releases[0].description.is_some());
|
|
assert_eq!(meta.releases[1].version, "5.1.0");
|
|
assert_eq!(meta.mime_types, vec!["image/png", "image/jpeg"]);
|
|
assert!(meta
|
|
.description
|
|
.unwrap()
|
|
.contains("Krita is a creative painting application"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_legacy_developer_name() {
|
|
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<component type="desktop-application">
|
|
<id>org.test.App</id>
|
|
<name>Test</name>
|
|
<summary>Test app</summary>
|
|
<developer_name>Old Developer Tag</developer_name>
|
|
</component>"#;
|
|
let meta = parse_appstream_xml(xml).unwrap();
|
|
assert_eq!(meta.developer.as_deref(), Some("Old Developer Tag"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_empty_returns_none() {
|
|
let xml = r#"<?xml version="1.0"?><component></component>"#;
|
|
assert!(parse_appstream_xml(xml).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_rating_levels() {
|
|
assert_eq!(summarize_content_rating(&[]), "All ages");
|
|
assert_eq!(
|
|
summarize_content_rating(&[
|
|
("violence-cartoon".to_string(), "none".to_string()),
|
|
("language-humor".to_string(), "mild".to_string()),
|
|
]),
|
|
"Mild content"
|
|
);
|
|
assert_eq!(
|
|
summarize_content_rating(&[(
|
|
"violence-realistic".to_string(),
|
|
"intense".to_string(),
|
|
)]),
|
|
"Mature content"
|
|
);
|
|
}
|
|
}
|