From a2a06e3542f8e9c1abb88ad7ced262c2f13494ac Mon Sep 17 00:00:00 2001 From: mtkennerly Date: Thu, 8 Aug 2024 23:57:17 -0400 Subject: [PATCH] Add filter for which manifest defined a game --- CHANGELOG.md | 1 + lang/en-US.ftl | 4 ++++ src/gui/app.rs | 9 ++++++++ src/gui/common.rs | 1 + src/gui/game_list.rs | 24 ++++++++++++++++--- src/gui/search.rs | 50 ++++++++++++++++++++++++++++++++++++++-- src/lang.rs | 12 ++++++++++ src/resource/manifest.rs | 32 +++++++++++++++++++++---- src/scan/game_filter.rs | 28 ++++++++++++++++++++++ 9 files changed, 152 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb530488..7484ef7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and you can click the icon to display the notes. The primary manifest does not (yet) contain any notes, so this mainly applies to secondary manifest authors. + * GUI: You can now filter games by which secondary manifest defined them. * CLI: The `api` command now supports a `checkAppUpdate` message. * Fixed: * CLI: Some commands would fail with relative path arguments. diff --git a/lang/en-US.ftl b/lang/en-US.ftl index b5087157..f1af442d 100644 --- a/lang/en-US.ftl +++ b/lang/en-US.ftl @@ -150,6 +150,10 @@ label-game = Game # Aliases are alternative titles for the same game. label-alias = Alias label-original-name = Original name +# Which manifest a game's data came from +label-source = Source +# This refers to the main Ludusavi manifest: https://github.com/mtkennerly/ludusavi-manifest +label-primary-manifest = Primary manifest store-ea = EA store-epic = Epic diff --git a/src/gui/app.rs b/src/gui/app.rs index ff0150f5..cb7d4b64 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -1956,6 +1956,15 @@ impl Application for App { search.change.choice = filter; Command::none() } + Message::EditedSearchFilterManifest(filter) => { + let search = if self.screen == Screen::Backup { + &mut self.backup_screen.log.search + } else { + &mut self.restore_screen.log.search + }; + search.manifest.choice = filter; + Command::none() + } Message::EditedSortKey { screen, value } => { match screen { Screen::Backup => { diff --git a/src/gui/common.rs b/src/gui/common.rs index b6645339..7c4f5968 100644 --- a/src/gui/common.rs +++ b/src/gui/common.rs @@ -188,6 +188,7 @@ pub enum Message { EditedSearchFilterCompleteness(game_filter::Completeness), EditedSearchFilterEnablement(game_filter::Enablement), EditedSearchFilterChange(game_filter::Change), + EditedSearchFilterManifest(game_filter::Manifest), EditedSortKey { screen: Screen, value: SortKey, diff --git a/src/gui/game_list.rs b/src/gui/game_list.rs index 2f01e390..5d55f3fc 100644 --- a/src/gui/game_list.rs +++ b/src/gui/game_list.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{BTreeSet, HashSet}; use iced::{alignment::Horizontal as HorizontalAlignment, keyboard::Modifiers, widget::tooltip, Alignment, Length}; @@ -18,9 +18,9 @@ use crate::{ resource::{ cache::Cache, config::{Config, Sort}, - manifest::{Manifest, Os}, + manifest::{self, Manifest, Os}, }, - scan::{layout::GameLayout, BackupInfo, DuplicateDetector, OperationStatus, ScanChange, ScanInfo}, + scan::{game_filter, layout::GameLayout, BackupInfo, DuplicateDetector, OperationStatus, ScanChange, ScanInfo}, }; #[derive(Default)] @@ -415,6 +415,7 @@ impl GameList { if restoring { Screen::Restore } else { Screen::Backup }, histories, config.scan.show_deselected_games, + self.manifests(manifest), ) }) .push({ @@ -433,6 +434,7 @@ impl GameList { !self.search.show || self.search.qualifies( &x.scan_info, + manifest, config.is_game_enabled_for_operation(&x.scan_info.game_name, restoring), duplicate_detector.is_game_duplicated(&x.scan_info.game_name), config.scan.show_deselected_games, @@ -751,4 +753,20 @@ impl GameList { layout.save(); } + + fn manifests(&self, manifest: &Manifest) -> Vec { + let mut manifests = BTreeSet::new(); + manifests.insert(&manifest::Source::Primary); + + for entry in &self.entries { + if let Some(data) = manifest.0.get(&entry.scan_info.game_name) { + manifests.extend(data.sources.iter()); + } + } + + manifests + .into_iter() + .map(|x| game_filter::Manifest::new(x.clone())) + .collect::>() + } } diff --git a/src/gui/search.rs b/src/gui/search.rs index ab862235..535c8d2b 100644 --- a/src/gui/search.rs +++ b/src/gui/search.rs @@ -8,6 +8,7 @@ use crate::{ widget::{checkbox, pick_list, text, Column, Element, IcedParentExt, Row}, }, lang::TRANSLATOR, + resource::manifest::Manifest, scan::{ game_filter::{self, FilterKind}, Duplication, ScanInfo, @@ -28,6 +29,7 @@ pub struct FilterComponent { pub completeness: Filter, pub enablement: Filter, pub change: Filter, + pub manifest: Filter, } fn template<'a, T: 'static + Default + Copy + Eq + PartialEq + ToString>( @@ -50,10 +52,28 @@ fn template<'a, T: 'static + Default + Copy + Eq + PartialEq + ToString>( .into() } +fn template_with_label( + filter: &Filter, + label: String, + kind: FilterKind, + options: Vec, + message: fn(T) -> Message, +) -> Element { + Row::new() + .spacing(10) + .align_items(Alignment::Center) + .push(checkbox(label, filter.active, move |enabled| { + Message::ToggledSearchFilter { filter: kind, enabled } + })) + .push(pick_list(options, Some(filter.choice.clone()), message)) + .into() +} + impl FilterComponent { pub fn qualifies( &self, scan: &ScanInfo, + manifest: &Manifest, enabled: bool, duplicated: Duplication, show_deselected_games: bool, @@ -66,8 +86,14 @@ impl FilterComponent { let complete = !self.completeness.active || self.completeness.choice.qualifies(scan); let enable = !show_deselected_games || !self.enablement.active || self.enablement.choice.qualifies(enabled); let changed = !self.change.active || self.change.choice.qualifies(scan); + let manifest = !self.manifest.active + || manifest + .0 + .get(&scan.game_name) + .map(|game| self.manifest.choice.qualifies(game)) + .unwrap_or_default(); - fuzzy && unique && complete && changed && enable + fuzzy && unique && complete && changed && enable && manifest } pub fn toggle_filter(&mut self, filter: FilterKind, enabled: bool) { @@ -76,10 +102,17 @@ impl FilterComponent { FilterKind::Completeness => self.completeness.active = enabled, FilterKind::Enablement => self.enablement.active = enabled, FilterKind::Change => self.change.active = enabled, + FilterKind::Manifest => self.manifest.active = enabled, } } - pub fn view(&self, screen: Screen, histories: &TextHistories, show_deselected_games: bool) -> Option { + pub fn view( + &self, + screen: Screen, + histories: &TextHistories, + show_deselected_games: bool, + manifests: Vec, + ) -> Option { if !self.show { return None; } @@ -128,6 +161,19 @@ impl FilterComponent { ) }), ) + .push_if(manifests.len() > 1, || { + Row::new() + .padding([0, 20, 20, 20]) + .spacing(20) + .align_items(Alignment::Center) + .push(template_with_label( + &self.manifest, + TRANSLATOR.source_field(), + FilterKind::Manifest, + manifests, + Message::EditedSearchFilterManifest, + )) + }) .into(), ) } diff --git a/src/lang.rs b/src/lang.rs index b188999e..705d6725 100644 --- a/src/lang.rs +++ b/src/lang.rs @@ -1018,6 +1018,18 @@ impl Translator { self.field(&self.original_name_label()) } + pub fn source_label(&self) -> String { + translate("label-source") + } + + pub fn source_field(&self) -> String { + self.field(&self.source_label()) + } + + pub fn primary_manifest_label(&self) -> String { + translate("label-primary-manifest") + } + pub fn custom_game_kind(&self, kind: &CustomGameKind) -> String { match kind { CustomGameKind::Game => self.game_label(), diff --git a/src/resource/manifest.rs b/src/resource/manifest.rs index 54d43217..9d715993 100644 --- a/src/resource/manifest.rs +++ b/src/resource/manifest.rs @@ -169,6 +169,13 @@ pub struct Secondary { pub data: Manifest, } +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum Source { + #[default] + Primary, + Secondary(String), +} + #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Manifest(#[serde(serialize_with = "crate::serialization::ordered_map")] pub HashMap); @@ -193,6 +200,8 @@ pub struct Game { pub cloud: CloudMetadata, #[serde(skip_serializing_if = "Vec::is_empty")] pub notes: Vec, + #[serde(skip)] + pub sources: BTreeSet, } impl Game { @@ -377,10 +386,17 @@ impl Manifest { } pub fn load() -> Result { - ResourceFile::load().map_err(|e| Error::ManifestInvalid { - why: format!("{}", e), - identifier: None, - }) + ResourceFile::load() + .map(|mut manifest: Self| { + for game in manifest.0.values_mut() { + game.sources.insert(Source::Primary); + } + manifest + }) + .map_err(|e| Error::ManifestInvalid { + why: format!("{}", e), + identifier: None, + }) } pub fn load_with_secondary(config: &Config) -> Result { @@ -602,6 +618,7 @@ impl Manifest { // you probably still want to back up your customized versions of such games. cloud: CloudMetadata::default(), notes: Default::default(), + sources: Default::default(), }; self.0.insert(name, game); @@ -659,6 +676,8 @@ impl Manifest { note.source = Some(secondary.id.clone()); } standard.notes.extend(game.notes); + + standard.sources.insert(Source::Secondary(secondary.id.clone())); } else { log::debug!("adding game from secondary manifest: {name}"); @@ -670,6 +689,8 @@ impl Manifest { note.source = Some(secondary.id.clone()); } + game.sources.insert(Source::Secondary(secondary.id.clone())); + self.0.insert(name, game); } } @@ -691,6 +712,7 @@ impl Manifest { id, cloud: _, notes: _, + sources: _, } = &v; alias.is_none() && (!files.is_empty() || !registry.is_empty() || !steam.is_empty() || !gog.is_empty() || !id.is_empty()) @@ -765,6 +787,7 @@ mod tests { id: Default::default(), cloud: Default::default(), notes: Default::default(), + sources: Default::default(), }, manifest.0["game"], ); @@ -853,6 +876,7 @@ mod tests { uplay: true }, notes: Default::default(), + sources: Default::default(), }, manifest.0["game"], ); diff --git a/src/scan/game_filter.rs b/src/scan/game_filter.rs index 3ca0aeda..405d3f38 100644 --- a/src/scan/game_filter.rs +++ b/src/scan/game_filter.rs @@ -1,5 +1,6 @@ use crate::{ lang::TRANSLATOR, + resource::manifest, scan::{Duplication, ScanInfo}, }; @@ -11,6 +12,7 @@ pub enum FilterKind { Completeness, Enablement, Change, + Manifest, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -112,3 +114,29 @@ impl Change { } } } + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Manifest { + source: manifest::Source, +} + +impl Manifest { + pub fn new(source: manifest::Source) -> Self { + Self { source } + } +} + +impl ToString for Manifest { + fn to_string(&self) -> String { + match &self.source { + manifest::Source::Primary => TRANSLATOR.primary_manifest_label(), + manifest::Source::Secondary(id) => id.to_string(), + } + } +} + +impl Manifest { + pub fn qualifies(&self, game: &manifest::Game) -> bool { + game.sources.contains(&self.source) + } +}