From 750056e5872428655f2ff2a0bf09be76e82107f5 Mon Sep 17 00:00:00 2001 From: mtkennerly Date: Fri, 19 Jul 2024 23:16:18 -0400 Subject: [PATCH] Add support for manifest notes --- CHANGELOG.md | 6 +++++ src/gui/app.rs | 1 + src/gui/button.rs | 9 +++++++ src/gui/common.rs | 6 ++++- src/gui/game_list.rs | 5 ++++ src/gui/icon.rs | 8 +++++- src/gui/modal.rs | 55 ++++++++++++++++++++++++++++++++-------- src/gui/style.rs | 5 +++- src/resource/config.rs | 16 +++++++++--- src/resource/manifest.rs | 52 +++++++++++++++++++++++++++++++------ src/scan.rs | 1 + src/scan/layout.rs | 5 ++++ src/scan/preview.rs | 6 ++++- 13 files changed, 148 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e78693b..30fad7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ * GUI: When left open, Ludusavi will automatically check for manifest updates once every 24 hours. Previously, this check only occurred when the app started. + * Manifests may now include a `notes` field. + If a game has notes and is found during a backup scan, + then the backup screen will show an info icon next to the game, + 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. * Fixed: * CLI: Some commands would fail with relative path arguments. * Changed: diff --git a/src/gui/app.rs b/src/gui/app.rs index aaf5fd2..d04cc44 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -1421,6 +1421,7 @@ impl Application for App { Message::Restore(phase) => self.handle_restore(phase), Message::ValidateBackups(phase) => self.handle_validation(phase), Message::CancelOperation => self.cancel_operation(), + Message::ShowGameNotes { game, notes } => self.show_modal(Modal::GameNotes { game, notes }), Message::EditedBackupTarget(text) => { self.text_histories.backup_target.push(&text); self.config.backup.path.reset(text); diff --git a/src/gui/button.rs b/src/gui/button.rs index 2029f2c..8b0f00e 100644 --- a/src/gui/button.rs +++ b/src/gui/button.rs @@ -12,6 +12,7 @@ use crate::{ }, lang::TRANSLATOR, prelude::{Finality, SyncDirection}, + resource::manifest, }; fn template(content: Text, action: Option, style: Option) -> Element { @@ -467,3 +468,11 @@ pub fn validate_backups<'a>(ongoing: &Operation) -> Element<'a> { matches!(ongoing, Operation::ValidateBackups { .. }).then_some(style::Button::Negative), ) } + +pub fn show_game_notes<'a>(game: String, notes: Vec) -> Element<'a> { + template( + Icon::Info.text_narrow(), + Some(Message::ShowGameNotes { game, notes }), + Some(style::Button::Bare), + ) +} diff --git a/src/gui/common.rs b/src/gui/common.rs index c90f508..b664533 100644 --- a/src/gui/common.rs +++ b/src/gui/common.rs @@ -15,7 +15,7 @@ use crate::{ BackupFormat, CloudFilter, CustomGameKind, RedirectKind, Root, SecondaryManifestConfigKind, SortKey, Theme, ZipCompression, }, - manifest::{Manifest, ManifestUpdate, Store}, + manifest::{self, Manifest, ManifestUpdate, Store}, }, scan::{ game_filter, @@ -240,6 +240,10 @@ pub enum Message { subject: ScrollSubject, position: iced::widget::scrollable::AbsoluteOffset, }, + ShowGameNotes { + game: String, + notes: Vec, + }, EditedBackupComment { game: String, comment: String, diff --git a/src/gui/game_list.rs b/src/gui/game_list.rs index b4b3251..73a4699 100644 --- a/src/gui/game_list.rs +++ b/src/gui/game_list.rs @@ -169,6 +169,11 @@ impl GameListEntry { .style(style::Container::Tooltip) }) }) + .push_maybe({ + (!self.scan_info.notes.is_empty()).then(|| { + button::show_game_notes(self.scan_info.game_name.clone(), self.scan_info.notes.clone()) + }) + }) .push_maybe({ self.scan_info .backup diff --git a/src/gui/icon.rs b/src/gui/icon.rs index 6769f76..ffd95f8 100644 --- a/src/gui/icon.rs +++ b/src/gui/icon.rs @@ -1,4 +1,4 @@ -use iced::alignment; +use iced::{alignment, Length}; use crate::gui::{ font, @@ -20,6 +20,7 @@ pub enum Icon { FastForward, Filter, FolderOpen, + Info, KeyboardArrowDown, KeyboardArrowRight, Language, @@ -56,6 +57,7 @@ impl Icon { Self::FastForward => '\u{E01F}', Self::Filter => '\u{ef4f}', Self::FolderOpen => '\u{E2C8}', + Self::Info => '\u{e88e}', Self::KeyboardArrowDown => '\u{E313}', Self::KeyboardArrowRight => '\u{E315}', Self::Language => '\u{E894}', @@ -87,6 +89,10 @@ impl Icon { .line_height(1.0) } + pub fn text_narrow(self) -> Text<'static> { + self.text().width(Length::Shrink) + } + pub fn text_small(self) -> Text<'static> { text(self.as_char().to_string()) .font(font::ICONS) diff --git a/src/gui/modal.rs b/src/gui/modal.rs index c979d77..13d9c74 100644 --- a/src/gui/modal.rs +++ b/src/gui/modal.rs @@ -9,13 +9,17 @@ use crate::{ badge::Badge, button, common::{BackupPhase, Message, RestorePhase, ScrollSubject, UndoSubject}, + icon::Icon, shortcuts::TextHistories, style, widget::{pick_list, text, Column, Container, Element, IcedParentExt, Row, Space}, }, lang::TRANSLATOR, prelude::{Error, Finality, SyncDirection}, - resource::config::{Config, Root}, + resource::{ + config::{Config, Root}, + manifest, + }, }; const CHANGES_PER_PAGE: usize = 500; @@ -148,13 +152,19 @@ pub enum Modal { ConfigureWebDavRemote { provider: WebDavProvider, }, + GameNotes { + game: String, + notes: Vec, + }, } impl Modal { pub fn variant(&self) -> ModalVariant { match self { Self::Exiting | Self::UpdatingManifest => ModalVariant::Loading, - Self::Error { .. } | Self::Errors { .. } | Self::NoMissingRoots => ModalVariant::Info, + Self::Error { .. } | Self::Errors { .. } | Self::NoMissingRoots | Self::GameNotes { .. } => { + ModalVariant::Info + } Self::ConfirmBackup { .. } | Self::ConfirmRestore { .. } | Self::ConfirmAddMissingRoots(..) @@ -221,14 +231,17 @@ impl Modal { Self::ConfigureFtpRemote { .. } => RemoteChoice::Ftp.to_string(), Self::ConfigureSmbRemote { .. } => RemoteChoice::Smb.to_string(), Self::ConfigureWebDavRemote { .. } => RemoteChoice::WebDav.to_string(), + Self::GameNotes { game, .. } => game.clone(), } } pub fn message(&self, histories: &TextHistories) -> Option { match self { - Self::Error { .. } | Self::Errors { .. } | Self::NoMissingRoots | Self::BackupValidation { .. } => { - Some(Message::CloseModal) - } + Self::Error { .. } + | Self::Errors { .. } + | Self::NoMissingRoots + | Self::BackupValidation { .. } + | Self::GameNotes { .. } => Some(Message::CloseModal), Self::Exiting => None, Self::ConfirmBackup { games } => Some(Message::Backup(BackupPhase::Start { preview: false, @@ -350,7 +363,8 @@ impl Modal { | Self::AppUpdate { .. } | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } - | Self::ConfigureWebDavRemote { .. } => vec![], + | Self::ConfigureWebDavRemote { .. } + | Self::GameNotes { .. } => vec![], } } @@ -437,6 +451,20 @@ impl Modal { ModalField::WebDavProvider, )); } + Self::GameNotes { notes, .. } => { + col = notes.iter().fold(col, |parent, note| { + parent.push( + Row::new() + .push(Container::new(Icon::Info.text_narrow()).padding([2, 10, 0, 5])) + .push( + Column::new() + .spacing(5) + .push(text(¬e.message).size(16)) + .push_maybe(note.source.as_ref().map(|source| text(source).size(12))), + ), + ) + }); + } } col @@ -460,7 +488,8 @@ impl Modal { | Self::UpdatingManifest | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } - | Self::ConfigureWebDavRemote { .. } => (), + | Self::ConfigureWebDavRemote { .. } + | Self::GameNotes { .. } => (), } } @@ -497,7 +526,8 @@ impl Modal { | Self::UpdatingManifest | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } - | Self::ConfigureWebDavRemote { .. } => (), + | Self::ConfigureWebDavRemote { .. } + | Self::GameNotes { .. } => (), } } @@ -518,7 +548,8 @@ impl Modal { | Self::UpdatingManifest | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } - | Self::ConfigureWebDavRemote { .. } => (), + | Self::ConfigureWebDavRemote { .. } + | Self::GameNotes { .. } => (), } } @@ -537,7 +568,8 @@ impl Modal { | Self::UpdatingManifest | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } - | Self::ConfigureWebDavRemote { .. } => false, + | Self::ConfigureWebDavRemote { .. } + | Self::GameNotes { .. } => false, } } @@ -556,7 +588,8 @@ impl Modal { | Self::UpdatingManifest | Self::ConfigureFtpRemote { .. } | Self::ConfigureSmbRemote { .. } - | Self::ConfigureWebDavRemote { .. } => 2, + | Self::ConfigureWebDavRemote { .. } + | Self::GameNotes { .. } => 2, } } diff --git a/src/gui/style.rs b/src/gui/style.rs index db72747..4c4ab57 100644 --- a/src/gui/style.rs +++ b/src/gui/style.rs @@ -149,6 +149,7 @@ pub enum Button { NavButtonActive, NavButtonInactive, Badge, + Bare, } impl button::StyleSheet for Theme { type Style = Button; @@ -165,6 +166,7 @@ impl button::StyleSheet for Theme { Self::Style::NavButtonActive => Some(self.navigation.alpha(0.9).into()), Self::Style::NavButtonInactive => None, Self::Style::Badge => None, + Self::Style::Bare => None, }, border: Border { color: match style { @@ -193,7 +195,7 @@ impl button::StyleSheet for Theme { text_color: match style { Self::Style::GameListEntryTitleDisabled => self.text_skipped.alpha(0.8), Self::Style::GameListEntryTitleUnscanned => self.text.alpha(0.8), - Self::Style::NavButtonInactive => self.text, + Self::Style::NavButtonInactive | Self::Style::Bare => self.text, _ => self.text_button.alpha(0.8), }, shadow: Shadow::default(), @@ -225,6 +227,7 @@ impl button::StyleSheet for Theme { text_color: match style { Self::Style::GameListEntryTitleDisabled => self.text_skipped, Self::Style::GameListEntryTitleUnscanned | Self::Style::NavButtonInactive => self.text, + Self::Style::Bare => self.text.alpha(0.9), _ => self.text_button, }, shadow_offset: match style { diff --git a/src/resource/config.rs b/src/resource/config.rs index 0004b67..0cd8279 100644 --- a/src/resource/config.rs +++ b/src/resource/config.rs @@ -12,7 +12,7 @@ use crate::{ path::CommonPath, prelude::{app_dir, Error, StrictPath, AVAILABLE_PARALELLISM}, resource::{ - manifest::{CloudMetadata, Manifest, Store}, + manifest::{self, CloudMetadata, Manifest, Store}, ResourceFile, SaveableResourceFile, }, scan::registry_compat::RegistryItem, @@ -92,7 +92,7 @@ impl ManifestConfig { .collect() } - pub fn load_secondary_manifests(&self) -> Vec<(StrictPath, Manifest)> { + pub fn load_secondary_manifests(&self) -> Vec { self.secondary .iter() .filter_map(|x| match x { @@ -105,7 +105,11 @@ impl ManifestConfig { if let Err(e) = &manifest { log::error!("Cannot load secondary manifest: {:?} | {}", &path, e); } - Some((path.clone(), manifest.ok()?)) + Some(manifest::Secondary { + id: path.render(), + path: path.clone(), + data: manifest.ok()?, + }) } SecondaryManifestConfig::Remote { url, enable } => { if !enable { @@ -117,7 +121,11 @@ impl ManifestConfig { if let Err(e) = &manifest { log::error!("Cannot load manifest: {:?} | {}", &path, e); } - Some((path.clone(), manifest.ok()?)) + Some(manifest::Secondary { + id: url.to_string(), + path: path.clone(), + data: manifest.ok()?, + }) } }) .collect() diff --git a/src/resource/manifest.rs b/src/resource/manifest.rs index 93654c7..a7cdb5e 100644 --- a/src/resource/manifest.rs +++ b/src/resource/manifest.rs @@ -162,6 +162,13 @@ pub enum Tag { Other, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Secondary { + pub id: String, + pub path: StrictPath, + pub data: Manifest, +} + #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Manifest(#[serde(serialize_with = "crate::serialization::ordered_map")] pub HashMap); @@ -184,6 +191,8 @@ pub struct Game { pub id: IdMetadata, #[serde(skip_serializing_if = "CloudMetadata::is_empty")] pub cloud: CloudMetadata, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, } impl Game { @@ -329,6 +338,14 @@ impl CloudMetadata { } } +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Note { + pub message: String, + #[serde(skip)] + pub source: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct ManifestUpdate { pub url: String, @@ -535,13 +552,17 @@ impl Manifest { self.0.clear(); } - for (path, secondary) in config.manifest.load_secondary_manifests() { - self.incorporate_secondary_manifest(path, secondary); + for secondary in config.manifest.load_secondary_manifests() { + self.incorporate_secondary_manifest(secondary); } for root in &config.roots { for (path, secondary) in root.find_secondary_manifests() { - self.incorporate_secondary_manifest(path, secondary); + self.incorporate_secondary_manifest(Secondary { + id: path.render(), + path, + data: secondary, + }); } } @@ -582,14 +603,17 @@ impl Manifest { // If you choose not to back up games with cloud support, // you probably still want to back up your customized versions of such games. cloud: CloudMetadata::default(), + notes: Default::default(), }; self.0.insert(name, game); } - fn incorporate_secondary_manifest(&mut self, path: StrictPath, secondary: Manifest) { - log::debug!("incorporating secondary manifest: {}", path.render()); - for (name, mut game) in secondary.0 { + fn incorporate_secondary_manifest(&mut self, secondary: Secondary) { + log::debug!("incorporating secondary manifest: {}", &secondary.id); + let manifest = secondary.data.0; + + for (name, mut game) in manifest { game.normalize_relative_paths(); if let Some(standard) = self.0.get_mut(&name) { @@ -598,7 +622,7 @@ impl Manifest { standard.files.extend(game.files); standard.registry.extend(game.registry); - if let Some(folder) = path.parent().and_then(|x| x.leaf()) { + if let Some(folder) = secondary.path.parent().and_then(|x| x.leaf()) { standard.install_dir.insert(folder, GameInstallDirEntry {}); } standard.install_dir.extend(game.install_dir); @@ -616,13 +640,22 @@ impl Manifest { } standard.id.gog_extra.extend(game.id.gog_extra); standard.id.steam_extra.extend(game.id.steam_extra); + + for note in &mut game.notes { + note.source = Some(secondary.id.clone()); + } + standard.notes.extend(game.notes); } else { log::debug!("adding game from secondary manifest: {name}"); - if let Some(folder) = path.parent().and_then(|x| x.leaf()) { + if let Some(folder) = secondary.path.parent().and_then(|x| x.leaf()) { game.install_dir.insert(folder, GameInstallDirEntry {}); } + for note in &mut game.notes { + note.source = Some(secondary.id.clone()); + } + self.0.insert(name, game); } } @@ -643,6 +676,7 @@ impl Manifest { gog, id, cloud: _, + notes: _, } = &v; alias.is_none() && (!files.is_empty() || !registry.is_empty() || !steam.is_empty() || !gog.is_empty() || !id.is_empty()) @@ -716,6 +750,7 @@ mod tests { gog: Default::default(), id: Default::default(), cloud: Default::default(), + notes: Default::default(), }, manifest.0["game"], ); @@ -803,6 +838,7 @@ mod tests { steam: true, uplay: true }, + notes: Default::default(), }, manifest.0["game"], ); diff --git a/src/scan.rs b/src/scan.rs index dc98b82..71cdb1d 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -793,6 +793,7 @@ pub fn scan_game_for_backup( available_backups: vec![], backup: None, has_backups, + notes: game.notes.clone(), } } diff --git a/src/scan/layout.rs b/src/scan/layout.rs index 7612459..62bf0d8 100644 --- a/src/scan/layout.rs +++ b/src/scan/layout.rs @@ -619,6 +619,7 @@ impl GameLayout { available_backups: vec![], backup: None, has_backups: true, + notes: vec![], }) } } @@ -1599,6 +1600,7 @@ impl GameLayout { available_backups, backup, has_backups, + notes: vec![], } } @@ -3358,6 +3360,7 @@ mod tests { available_backups: backups.clone(), backup: Some(backups[0].clone()), has_backups: true, + notes: vec![], }, layout.scan_for_restoration( "game1", @@ -3407,6 +3410,7 @@ mod tests { ..Default::default() })), has_backups: true, + notes: vec![], }, layout.scan_for_restoration( "game3", @@ -3439,6 +3443,7 @@ mod tests { ..Default::default() })), has_backups: true, + notes: vec![], }, layout.scan_for_restoration( "game3", diff --git a/src/scan/preview.rs b/src/scan/preview.rs index 31081e0..b69d0b1 100644 --- a/src/scan/preview.rs +++ b/src/scan/preview.rs @@ -1,7 +1,10 @@ use std::collections::HashSet; use crate::{ - resource::config::{ToggledPaths, ToggledRegistry}, + resource::{ + config::{ToggledPaths, ToggledRegistry}, + manifest, + }, scan::{layout::Backup, BackupInfo, ScanChange, ScanChangeCount, ScannedFile, ScannedRegistry}, }; @@ -16,6 +19,7 @@ pub struct ScanInfo { pub backup: Option, /// Cheaper version of `!available_backups.is_empty()`, always populated. pub has_backups: bool, + pub notes: Vec, } impl ScanInfo {