diff --git a/Cargo.toml b/Cargo.toml index f909711..24dfe91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "freedesktop-desktop-entry" -version = "0.5.4" +version = "0.6.0" authors = ["Michael Aaron Murphy "] edition = "2021" homepage = "https://github.com/pop-os/freedesktop-desktop-entry" @@ -18,3 +18,4 @@ textdistance = "1.0.2" strsim = "0.11.1" thiserror = "1" xdg = "2.4.0" +log = "0.4.21" \ No newline at end of file diff --git a/src/decoder.rs b/src/decoder.rs index 3fac2b8..cef1256 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -1,3 +1,6 @@ +// Copyright 2021 System76 +// SPDX-License-Identifier: MPL-2.0 + use std::{ borrow::Cow, collections::BTreeMap, @@ -68,10 +71,7 @@ impl<'a> DesktopEntry<'a> { } /// Return an owned [`DesktopEntry`] - pub fn from_path( - path: PathBuf, - locales: &[L], - ) -> Result, DecodeError> + pub fn from_path(path: PathBuf, locales: &[L]) -> Result, DecodeError> where L: AsRef, { @@ -194,7 +194,7 @@ where } /// Ex: if a locale equal fr_FR, add fr -fn add_generic_locales<'a, L: AsRef>(locales: &'a [L]) -> Vec<&'a str> { +fn add_generic_locales>(locales: &[L]) -> Vec<&str> { let mut v = Vec::with_capacity(locales.len() + 1); for l in locales { diff --git a/src/iter.rs b/src/iter.rs index bdaedcc..1d90669 100644 --- a/src/iter.rs +++ b/src/iter.rs @@ -37,7 +37,6 @@ impl Iterator for Iter { Err(_) => continue, } } - return None; } diff --git a/src/lib.rs b/src/lib.rs index b911c26..17d4ffd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,12 +6,10 @@ mod iter; pub mod matching; -#[cfg(test)] -mod test; - pub use self::iter::Iter; use std::borrow::Cow; use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use xdg::BaseDirectories; @@ -24,7 +22,7 @@ pub type Locale<'a> = Cow<'a, str>; pub type LocaleMap<'a> = BTreeMap, Value<'a>>; pub type Value<'a> = Cow<'a, str>; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Eq)] pub struct DesktopEntry<'a> { pub appid: Cow<'a, str>, pub groups: Groups<'a>, @@ -32,10 +30,22 @@ pub struct DesktopEntry<'a> { pub ubuntu_gettext_domain: Option>, } +impl Hash for DesktopEntry<'_> { + fn hash(&self, state: &mut H) { + self.appid.hash(state); + } +} + +impl PartialEq for DesktopEntry<'_> { + fn eq(&self, other: &Self) -> bool { + self.appid == other.appid + } +} + impl DesktopEntry<'_> { /// Construct a new [`DesktopEntry`] from an appid. The name field will be /// set to that appid. - pub fn from_appid<'a>(appid: &'a str) -> DesktopEntry<'a> { + pub fn from_appid(appid: &str) -> DesktopEntry<'_> { let mut de = DesktopEntry { appid: Cow::Borrowed(appid), groups: Groups::default(), @@ -77,12 +87,10 @@ impl<'a> DesktopEntry<'a> { DesktopEntry { appid: Cow::Owned(self.appid.to_string()), groups: new_groups, - ubuntu_gettext_domain: if let Some(ubuntu_gettext_domain) = &self.ubuntu_gettext_domain - { - Some(Cow::Owned(ubuntu_gettext_domain.to_string())) - } else { - None - }, + ubuntu_gettext_domain: self + .ubuntu_gettext_domain + .as_ref() + .map(|ubuntu_gettext_domain| Cow::Owned(ubuntu_gettext_domain.to_string())), path: Cow::Owned(self.path.to_path_buf()), } } @@ -93,9 +101,9 @@ impl<'a> DesktopEntry<'a> { self.appid.as_ref() } - /// A desktop entry field is any field under the `[Desktop Entry]` section. + /// A desktop entry field if any field under the `[Desktop Entry]` section. pub fn desktop_entry(&'a self, key: &str) -> Option<&'a str> { - Self::entry(self.groups.get("Desktop Entry"), key).map(|e| e.as_ref()) + Self::entry(self.groups.get("Desktop Entry"), key) } pub fn desktop_entry_localized>( @@ -154,27 +162,35 @@ impl<'a> DesktopEntry<'a> { self.desktop_entry("Exec") } - /// Return categories separated by `;` - pub fn categories(&'a self) -> Option<&'a str> { + /// Return categories + pub fn categories(&'a self) -> Option> { self.desktop_entry("Categories") + .map(|e| e.split(';').collect()) } - /// Return keywords separated by `;` - pub fn keywords>(&'a self, locales: &[L]) -> Option> { - self.desktop_entry_localized("Keywords", locales) + /// Return keywords + pub fn keywords>(&'a self, locales: &[L]) -> Option>> { + self.localized_entry_splitted(self.groups.get("Desktop Entry"), "Keywords", locales) } - /// Return mime types separated by `;` - pub fn mime_type(&'a self) -> Option<&'a str> { + /// Return mime types + pub fn mime_type(&'a self) -> Option> { self.desktop_entry("MimeType") + .map(|e| e.split(';').collect()) } pub fn no_display(&'a self) -> bool { self.desktop_entry_bool("NoDisplay") } - pub fn only_show_in(&'a self) -> Option<&'a str> { + pub fn only_show_in(&'a self) -> Option> { self.desktop_entry("OnlyShowIn") + .map(|e| e.split(';').collect()) + } + + pub fn not_show_in(&'a self) -> Option> { + self.desktop_entry("NotShowIn") + .map(|e| e.split(';').collect()) } pub fn flatpak(&'a self) -> Option<&'a str> { @@ -201,9 +217,9 @@ impl<'a> DesktopEntry<'a> { self.desktop_entry("Type") } - /// Return actions separated by `;` - pub fn actions(&'a self) -> Option<&'a str> { + pub fn actions(&'a self) -> Option> { self.desktop_entry("Actions") + .map(|e| e.split(';').collect()) } /// An action is defined as `[Desktop Action actions-name]` where `action-name` @@ -214,7 +230,7 @@ impl<'a> DesktopEntry<'a> { /// Name=Open a New Window /// ``` /// you will need to call - /// ```rust + /// ```ignore /// entry.action_entry("new-window", "Name") /// ``` pub fn action_entry(&'a self, action: &str, key: &str) -> Option<&'a str> { @@ -266,13 +282,7 @@ impl<'a> DesktopEntry<'a> { key: &str, locales: &[L], ) -> Option> { - let Some(group) = group else { - return None; - }; - - let Some((default_value, locale_map)) = group.get(key) else { - return None; - }; + let (default_value, locale_map) = group?.get(key)?; for locale in locales { match locale_map.get(locale.as_ref()) { @@ -287,9 +297,43 @@ impl<'a> DesktopEntry<'a> { } } if let Some(domain) = ubuntu_gettext_domain { - return Some(Cow::Owned(dgettext(domain, &default_value))); + return Some(Cow::Owned(dgettext(domain, default_value))); + } + Some(default_value.clone()) + } + + pub fn localized_entry_splitted>( + &self, + group: Option<&'a KeyMap<'a>>, + key: &str, + locales: &[L], + ) -> Option>> { + let (default_value, locale_map) = group?.get(key)?; + + for locale in locales { + match locale_map.get(locale.as_ref()) { + Some(value) => { + return Some(value.split(';').map(Cow::Borrowed).collect()); + } + None => { + if let Some(pos) = memchr::memchr(b'_', locale.as_ref().as_bytes()) { + if let Some(value) = locale_map.get(&locale.as_ref()[..pos]) { + return Some(value.split(';').map(Cow::Borrowed).collect()); + } + } + } + } + } + if let Some(domain) = &self.ubuntu_gettext_domain { + return Some( + dgettext(domain, default_value) + .split(';') + .map(|e| Cow::Owned(e.to_string())) + .collect(), + ); } - return Some(default_value.clone()); + + Some(default_value.split(';').map(Cow::Borrowed).collect()) } } @@ -403,9 +447,40 @@ pub fn get_languages_from_env() -> Vec { l } +pub fn current_desktop() -> Option> { + std::env::var("XDG_CURRENT_DESKTOP").ok().map(|x| { + let x = x.to_ascii_lowercase(); + if x == "unity" { + vec!["gnome".to_string()] + } else { + x.split(':').map(|e| e.to_string()).collect() + } + }) +} + #[test] -fn locales_env_test() { +fn add_field() { + let appid = "appid"; + let de = DesktopEntry::from_appid(appid); + + assert_eq!(de.appid, appid); + assert_eq!(de.name(&[] as &[&str]).unwrap(), appid); + let s = get_languages_from_env(); println!("{:?}", s); } + +#[test] +fn env_with_locale() { + let locales = &["fr_FR"]; + + let de = DesktopEntry::from_path(PathBuf::from("tests/org.mozilla.firefox.desktop"), locales) + .unwrap(); + + assert_eq!(de.generic_name(locales).unwrap(), "Navigateur Web"); + + let locales = &["nb"]; + + assert_eq!(de.generic_name(locales).unwrap(), "Web Browser"); +} diff --git a/src/matching.rs b/src/matching.rs index 025ac55..95e50ac 100644 --- a/src/matching.rs +++ b/src/matching.rs @@ -1,5 +1,10 @@ +// Copyright 2021 System76 +// SPDX-License-Identifier: MPL-2.0 + use std::cmp::max; +use log::debug; + use crate::DesktopEntry; /// The returned value is between 0.0 and 1.0 (higher value means more similar). @@ -14,53 +19,59 @@ where Q: AsRef, L: AsRef, { - // let the user do this ? - let query = query.as_ref().to_lowercase(); - - // todo: cache all this ? + #[inline] + fn add_value(v: &mut Vec, value: &str, is_multiple: bool) { + if is_multiple { + value.split(';').for_each(|e| v.push(e.to_lowercase())); + } else { + v.push(value.to_lowercase()); + } + } - let fields = ["Name", "GenericName", "Comment", "Categories", "Keywords"]; - let fields_not_translatable = ["Exec", "StartupWMClass"]; + // (field name, is separated by ";") + let fields = [ + ("Name", false), + ("GenericName", false), + ("Comment", false), + ("Categories", true), + ("Keywords", true), + ]; let mut normalized_values: Vec = Vec::new(); normalized_values.extend(additional_values.iter().map(|val| val.to_lowercase())); - let de_id = entry.appid.to_lowercase(); - let de_wm_class = entry.startup_wm_class().unwrap_or_default().to_lowercase(); - - normalized_values.push(de_id); - normalized_values.push(de_wm_class); - let desktop_entry_group = entry.groups.get("Desktop Entry"); - for field in fields_not_translatable { - if let Some(e) = DesktopEntry::entry(desktop_entry_group, field) { - normalized_values.push(e.to_lowercase()); - } - } + for field in fields { + if let Some(group) = desktop_entry_group { + if let Some((default_value, locale_map)) = group.get(field.0) { + add_value(&mut normalized_values, default_value, field.1); - for locale in locales { - for field in fields { - if let Some(group) = desktop_entry_group { - if let Some((default_value, locale_map)) = group.get(field) { + let mut at_least_one_locale = false; + + for locale in locales { match locale_map.get(locale.as_ref()) { Some(value) => { - normalized_values.push(value.to_lowercase()); + add_value(&mut normalized_values, value, field.1); + at_least_one_locale = true; } None => { if let Some(pos) = locale.as_ref().find('_') { if let Some(value) = locale_map.get(&locale.as_ref()[..pos]) { - normalized_values.push(value.to_lowercase()); + add_value(&mut normalized_values, value, field.1); + at_least_one_locale = true; } } } } + } + if !at_least_one_locale { if let Some(domain) = &entry.ubuntu_gettext_domain { - let gettext_value = crate::dgettext(domain, &default_value); + let gettext_value = crate::dgettext(domain, default_value); if !gettext_value.is_empty() { - normalized_values.push(gettext_value.to_lowercase()); + add_value(&mut normalized_values, &gettext_value, false); } } } @@ -68,6 +79,8 @@ where } } + let query = query.as_ref().to_lowercase(); + normalized_values .into_iter() .map(|de| strsim::jaro_winkler(&query, &de)) @@ -84,15 +97,30 @@ fn compare_str<'a>(pattern: &'a str, de_value: &'a str) -> f64 { /// From 0 to 1. /// 1 is a perfect match. fn match_entry_from_id(pattern: &str, de: &DesktopEntry) -> f64 { - let de_id = de.appid.to_lowercase(); - let de_wm_class = de.startup_wm_class().unwrap_or_default().to_lowercase(); - let de_name = de.name(&[] as &[&str]).unwrap_or_default().to_lowercase(); + // (pattern, malus) + let mut de_inputs = Vec::with_capacity(4); - *[de_id, de_wm_class, de_name] - .map(|de| compare_str(pattern, &de)) + let id = de.appid.to_lowercase(); + + if let Some(last_part_of_id) = id.split('.').last() { + de_inputs.push((last_part_of_id.to_owned(), 0.06)); + } + + de_inputs.push((id, 0.)); + + if let Some(i) = de.startup_wm_class() { + de_inputs.push((i.to_lowercase(), 0.)); + } + + if let Some(i) = de.exec() { + de_inputs.push((i.to_lowercase(), 0.06)); + } + + de_inputs .iter() + .map(|de| (compare_str(pattern, &de.0) - de.1).max(0.)) .max_by(|e1, e2| e1.total_cmp(e2)) - .unwrap_or(&0.0) + .unwrap_or(0.0) } #[derive(Debug, Clone)] @@ -111,14 +139,15 @@ pub struct MatchAppIdOptions { impl Default for MatchAppIdOptions { fn default() -> Self { Self { - min_score: 0.7, - entropy: Some((0.15, 0.2)), + min_score: 0.15, + entropy: Some((0.15, 0.1)), } } } /// Return the best match over all provided [`DesktopEntry`]. /// Use this to match over the values provided by the compositor, not the user. +/// First entries get the priority. pub fn get_best_match<'a, I>( patterns: &[I], entries: &'a [DesktopEntry<'a>], @@ -133,6 +162,9 @@ where let normalized_patterns = patterns .iter() .map(|e| e.as_ref().to_lowercase()) + .inspect(|e| { + debug!("searching with {}", e); + }) .collect::>(); for de in entries { @@ -145,11 +177,23 @@ where match max_score { Some((prev_max_score, _)) => { if prev_max_score < score { + debug!( + "found {} for {}. Score: {}", + de.appid, + patterns[0].as_ref(), + score + ); second_max_score = prev_max_score; max_score = Some((score, de)); } } None => { + debug!( + "found: {} for {}. Score: {}", + de.appid, + patterns[0].as_ref(), + score + ); max_score = Some((score, de)); } } @@ -177,3 +221,28 @@ where None } } + +#[cfg(test)] +mod test { + use crate::{default_paths, get_languages_from_env, matching::compare_str, DesktopEntry, Iter}; + + use super::{get_best_match, MatchAppIdOptions}; + + #[test] + fn find_de() { + let entries = + DesktopEntry::from_paths(Iter::new(default_paths()), &get_languages_from_env()) + .filter_map(|e| e.ok()) + .collect::>(); + + let e = get_best_match(&["gnome-disks"], &entries, MatchAppIdOptions::default()); + + println!("found {}", e.unwrap().appid); + } + #[test] + fn a() { + let res = compare_str("org.gnome.tweaks", "gnome.disks"); + + println!("{res}") + } +} diff --git a/src/test.rs b/src/test.rs deleted file mode 100644 index 8588778..0000000 --- a/src/test.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::path::Path; - -use crate::DesktopEntry; - -#[test] -fn test() { - let path = Path::new("tests/org.mozilla.firefox.desktop"); - - let locales = &["fr", "fr_FR.UTF-8"]; - - if let Ok(entry) = DesktopEntry::from_path(path.to_path_buf(), locales) { - let e = DesktopEntry::localized_entry( - None, - entry.groups.get("Desktop Entry"), - "GenericName", - &["fr"], - ) - .unwrap(); - - assert_eq!(e, "Navigateur Web"); - } -} diff --git a/tests/org.kde.krita.desktop b/tests/org.kde.krita.desktop new file mode 100644 index 0000000..e7aa8cc --- /dev/null +++ b/tests/org.kde.krita.desktop @@ -0,0 +1,168 @@ +[Desktop Entry] +Name=Krita +Name[af]=Krita +Name[ar]=كريتا +Name[bg]=Krita +Name[br]=Krita +Name[bs]=Krita +Name[ca]=Krita +Name[ca@valencia]=Krita +Name[cs]=Krita +Name[cy]=Krita +Name[da]=Krita +Name[de]=Krita +Name[el]=Krita +Name[en_GB]=Krita +Name[eo]=Krita +Name[es]=Krita +Name[et]=Krita +Name[eu]=Krita +Name[fi]=Krita +Name[fr]=Krita +Name[fy]=Krita +Name[ga]=Krita +Name[gl]=Krita +Name[he]=Krita +Name[hi]=क्रिता +Name[hne]=केरिता +Name[hr]=Krita +Name[hu]=Krita +Name[ia]=Krita +Name[id]=Krita +Name[is]=Krita +Name[it]=Krita +Name[ja]=Krita +Name[ka]=Krita +Name[kk]=Krita +Name[ko]=Krita +Name[lt]=Krita +Name[lv]=Krita +Name[mr]=क्रिटा +Name[ms]=Krita +Name[nds]=Krita +Name[ne]=क्रिता +Name[nl]=Krita +Name[nn]=Krita +Name[pl]=Krita +Name[pt]=Krita +Name[pt_BR]=Krita +Name[ro]=Krita +Name[ru]=Krita +Name[se]=Krita +Name[sk]=Krita +Name[sl]=Krita +Name[sv]=Krita +Name[ta]=கிரிட்டா +Name[tg]=Krita +Name[tr]=Krita +Name[ug]=Krita +Name[uk]=Krita +Name[uz]=Krita +Name[uz@cyrillic]=Krita +Name[wa]=Krita +Name[xh]=Krita +Name[x-test]=xxKritaxx +Name[zh_CN]=Krita +Name[zh_TW]=Krita +Exec=/usr/bin/flatpak run --branch=stable --arch=x86_64 --command=krita --file-forwarding org.kde.krita @@ %F @@ +GenericName=Digital Painting +GenericName[ar]=رسم رقمي +GenericName[bs]=Digitalno Bojenje +GenericName[ca]=Dibuix digital +GenericName[ca@valencia]=Dibuix digital +GenericName[cs]=Digitální malování +GenericName[da]=Digital tegning +GenericName[de]=Digitales Malen +GenericName[el]=Ψηφιακή ζωγραφική +GenericName[en_GB]=Digital Painting +GenericName[eo]=Cifereca Pentrado +GenericName[es]=Pintura digital +GenericName[et]=Digitaalne joonistamine +GenericName[eu]=Margolan digitala +GenericName[fi]=Digitaalimaalaus +GenericName[fr]=Peinture numérique +GenericName[gl]=Pintura dixital +GenericName[hi]=डिजिटल चित्रकारी +GenericName[hu]=Digitális festészet +GenericName[ia]=Pintura Digital +GenericName[id]=Pelukisan Digital +GenericName[is]=Stafræn málun +GenericName[it]=Pittura digitale +GenericName[ja]=デジタルペインティング +GenericName[ka]=ციფრული მხატვრობა +GenericName[kk]=Цифрлық сурет салу +GenericName[ko]=디지털 페인팅 +GenericName[lt]=Skaitmeninis piešimas +GenericName[mr]=डिजिटल पेंटिंग +GenericName[nl]=Digitaal schilderen +GenericName[nn]=Digital teikning +GenericName[pl]=Malowanie cyfrowe +GenericName[pt]=Pintura Digital +GenericName[pt_BR]=Pintura digital +GenericName[ro]=Pictură digitală +GenericName[ru]=Цифровая живопись +GenericName[sk]=Digitálne maľovanie +GenericName[sl]=Digitalno slikanje +GenericName[sv]=Digital målning +GenericName[tr]=Sayısal Boyama +GenericName[ug]=سىفىرلىق رەسىم سىزغۇ +GenericName[uk]=Цифрове малювання +GenericName[x-test]=xxDigital Paintingxx +GenericName[zh_CN]=数字绘画程序 +GenericName[zh_TW]=數位繪畫 +MimeType=application/x-krita;image/openraster;application/x-krita-paintoppreset; +Comment=Digital Painting +Comment[ar]=رسم رقمي +Comment[bs]=Digitalno Bojenje +Comment[ca]=Dibuix digital +Comment[ca@valencia]=Dibuix digital +Comment[cs]=Digitální malování +Comment[da]=Digital tegning +Comment[de]=Digitales Malen +Comment[el]=Ψηφιακή ζωγραφική +Comment[en_GB]=Digital Painting +Comment[eo]=Cifereca Pentrado +Comment[es]=Pintura digital +Comment[et]=Digitaalne joonistamine +Comment[eu]=Margolan digitala +Comment[fi]=Digitaalimaalaus +Comment[fr]=Peinture numérique +Comment[gl]=Pintura dixital. +Comment[hi]=डिजिटल चित्रकारी +Comment[hu]=Digitális festészet +Comment[ia]=Pintura Digital +Comment[id]=Pelukisan Digital +Comment[is]=Stafræn málun +Comment[it]=Pittura digitale +Comment[ja]=デジタルペインティング +Comment[ka]=ციფრული მხატვრობა +Comment[kk]=Цифрлық сурет салу +Comment[ko]=디지털 페인팅 +Comment[lt]=Skaitmeninis piešimas +Comment[mr]=डिजिटल पेंटिंग +Comment[nl]=Digitaal schilderen +Comment[nn]=Digital teikning +Comment[pl]=Malowanie cyfrowe +Comment[pt]=Pintura Digital +Comment[pt_BR]=Pintura digital +Comment[ro]=Pictură digitală +Comment[ru]=Цифровая живопись +Comment[sk]=Digitálne maľovanie +Comment[sl]=Digitalno slikanje +Comment[sv]=Digitalt målningsverktyg +Comment[tr]=Sayısal Boyama +Comment[ug]=سىفىرلىق رەسىم سىزغۇ +Comment[uk]=Цифрове малювання +Comment[x-test]=xxDigital Paintingxx +Comment[zh_CN]=自由开源的专业数字绘画程序 +Comment[zh_TW]=數位繪畫 +Type=Application +Icon=org.kde.krita +Categories=Qt;KDE;Graphics;2DGraphics;RasterGraphics; +X-KDE-NativeMimeType=application/x-krita +X-KDE-ExtraNativeMimeTypes= +StartupNotify=true +X-Krita-Version=28 +StartupWMClass=krita +InitialPreference=99 +X-Flatpak=org.kde.krita \ No newline at end of file