From 3bb37cf20b8ebab85365f80b14e28acc6d7ea529 Mon Sep 17 00:00:00 2001 From: Paul Hebble Date: Sun, 20 Aug 2023 15:10:38 -0500 Subject: [PATCH] Game-specific labels Track labels' module identifiers per game Create JsonToGamesDictionaryConverter --- .../JsonToGamesDictionaryConverter.cs | 103 ++++++++++++++++++ Core/GameInstanceManager.cs | 7 ++ GUI/Controls/Changeset.cs | 3 +- GUI/Controls/ManageMods.cs | 22 ++-- GUI/Controls/ModInfo.cs | 2 +- GUI/Labels/ModuleLabel.cs | 54 ++++++++- GUI/Main/MainLabels.cs | 16 ++- GUI/Model/ModList.cs | 23 ++-- GUI/Model/ModSearch.cs | 3 +- Tests/GUI/Model/ModList.cs | 8 +- 10 files changed, 201 insertions(+), 40 deletions(-) create mode 100644 Core/Converters/JsonToGamesDictionaryConverter.cs diff --git a/Core/Converters/JsonToGamesDictionaryConverter.cs b/Core/Converters/JsonToGamesDictionaryConverter.cs new file mode 100644 index 0000000000..c348a3c6e7 --- /dev/null +++ b/Core/Converters/JsonToGamesDictionaryConverter.cs @@ -0,0 +1,103 @@ +using System; +using System.Linq; +using System.Collections; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace CKAN +{ + /// + /// A property converter for making an old property game-specific. + /// Turns a String or Array value: + /// + /// "myProperty": "a value", + /// "myOtherProperty": [ "another value" ], + /// + /// into a Dictionary with the game names as keys and the original + /// value as each value: + /// + /// "myProperty": { + /// "KSP": "a value", + /// "KSP2": "a value" + /// }, + /// "myOtherProperty": { + /// "KSP": [ "another value" ], + /// "KSP2": [ "another value" ] + /// }, + /// + /// NOTE: Do NOT use with Object values because they can't + /// be distinguished from an already converted value, and will + /// just be deserialized as-is into your Dictionary! + /// + /// If the value is an empty array: + /// + /// "myProperty": [], + /// + /// the Dictionary is left empty rather than creating multiple keys + /// with empty values: + /// + /// "myProperty": {}, + /// + public class JsonToGamesDictionaryConverter : JsonConverter + { + /// + /// Turn a tree of JSON tokens into a dictionary + /// + /// Object that provides tokens to be translated + /// The output type to be populated + /// Not used + /// Generates output objects from tokens + /// Dictionary of type matching the property where this converter was used, containing game-specific keys and values + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var token = JToken.Load(reader); + if (token.Type == JTokenType.Object) + { + return token.ToObject(objectType); + } + var valueType = objectType.GetGenericArguments()[1]; + var obj = (IDictionary)Activator.CreateInstance(objectType); + if (!IsTokenEmpty(token)) + { + foreach (var gameName in GameInstanceManager.AllGameShortNames()) + { + // Make a new copy of the value for each game + obj.Add(gameName, token.ToObject(valueType)); + } + } + return obj; + } + + /// + /// We don't want to make any changes during serialization + /// + public override bool CanWrite => false; + + /// + /// We don't want to make any changes during serialization + /// + /// The object writing JSON to disk + /// A value to be written for this class + /// Generates output objects from tokens + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + + /// + /// We *only* want to be triggered for types that have explicitly + /// set an attribute in their class saying they can be converted. + /// By returning false here, we declare we're not interested in participating + /// in any other conversions. + /// + /// + /// false + /// + public override bool CanConvert(Type object_type) => false; + + private static bool IsTokenEmpty(JToken token) + => token.Type == JTokenType.Null + || (token.Type == JTokenType.Array && !token.HasValues); + } +} diff --git a/Core/GameInstanceManager.cs b/Core/GameInstanceManager.cs index 90babcedc1..67bf3a317a 100644 --- a/Core/GameInstanceManager.cs +++ b/Core/GameInstanceManager.cs @@ -649,5 +649,12 @@ public IGame DetermineGame(DirectoryInfo path, IUser user) public static IGame GameByShortName(string shortName) => knownGames.FirstOrDefault(g => g.ShortName == shortName); + /// + /// Return the short names of all known games + /// + /// Sequence of short name strings + public static IEnumerable AllGameShortNames() + => knownGames.Select(g => g.ShortName); + } } diff --git a/GUI/Controls/Changeset.cs b/GUI/Controls/Changeset.cs index 0d1152cad1..0b1aca8148 100644 --- a/GUI/Controls/Changeset.cs +++ b/GUI/Controls/Changeset.cs @@ -86,7 +86,8 @@ private ListViewItem makeItem(ModChange change, Dictionary c { var descr = change.Description; CkanModule m = change.Mod; - ModuleLabel warnLbl = alertLabels?.FirstOrDefault(l => l.ModuleIdentifiers.Contains(m.identifier)); + ModuleLabel warnLbl = alertLabels?.FirstOrDefault(l => + l.ContainsModule(Main.Instance.CurrentInstance.game, m.identifier)); return new ListViewItem(new string[] { change.NameAndStatus, diff --git a/GUI/Controls/ManageMods.cs b/GUI/Controls/ManageMods.cs index 99eb69d1e3..5830e48c29 100644 --- a/GUI/Controls/ManageMods.cs +++ b/GUI/Controls/ManageMods.cs @@ -166,6 +166,7 @@ private void ConflictsUpdated(Dictionary prevConflicts) { // Mark old conflicts as non-conflicted // (rows that are _still_ conflicted will be marked as such in the next loop) + var inst = Main.Instance.CurrentInstance; foreach (GUIMod guiMod in prevConflicts.Keys) { DataGridViewRow row = mainModList.full_list_of_mod_rows[guiMod.Identifier]; @@ -174,7 +175,7 @@ private void ConflictsUpdated(Dictionary prevConflicts) { cell.ToolTipText = null; } - mainModList.ReapplyLabels(guiMod, false, Main.Instance.CurrentInstance.Name); + mainModList.ReapplyLabels(guiMod, false, inst.Name, inst.game); if (row.Visible) { ModGrid.InvalidateRow(row.Index); @@ -247,7 +248,7 @@ private void FilterLabelsToolButton_DropDown_Opening(object sender, CancelEventA foreach (ModuleLabel mlbl in mainModList.ModuleLabels.LabelsFor(Main.Instance.CurrentInstance.Name)) { FilterLabelsToolButton.DropDownItems.Add(new ToolStripMenuItem( - $"{mlbl.Name} ({mlbl.ModuleIdentifiers.Count})", + $"{mlbl.Name} ({mlbl.ModuleCount(Main.Instance.CurrentInstance.game)})", null, customFilterButton_Click ) { @@ -271,7 +272,7 @@ private void LabelsContextMenuStrip_Opening(object sender, CancelEventArgs e) LabelsContextMenuStrip.Items.Add( new ToolStripMenuItem(mlbl.Name, null, labelMenuItem_Click) { - Checked = mlbl.ModuleIdentifiers.Contains(module.Identifier), + Checked = mlbl.ContainsModule(Main.Instance.CurrentInstance.game, module.Identifier), CheckOnClick = true, Tag = mlbl, } @@ -289,18 +290,19 @@ private void labelMenuItem_Click(object sender, EventArgs e) var module = SelectedModule; if (item.Checked) { - mlbl.Add(module.Identifier); + mlbl.Add(Main.Instance.CurrentInstance.game, module.Identifier); } else { - mlbl.Remove(module.Identifier); + mlbl.Remove(Main.Instance.CurrentInstance.game, module.Identifier); } if (mlbl.HoldVersion) { UpdateAllToolButton.Enabled = mainModList.Modules.Any(mod => mod.HasUpdate && !Main.Instance.LabelsHeld(mod.Identifier)); } - mainModList.ReapplyLabels(module, Conflicts?.ContainsKey(module) ?? false, Main.Instance.CurrentInstance.Name); + var inst = Main.Instance.CurrentInstance; + mainModList.ReapplyLabels(module, Conflicts?.ContainsKey(module) ?? false, inst.Name, inst.game); mainModList.ModuleLabels.Save(ModuleLabelList.DefaultPath); } @@ -310,9 +312,10 @@ private void editLabelsToolStripMenuItem_Click(object sender, EventArgs e) eld.ShowDialog(this); eld.Dispose(); mainModList.ModuleLabels.Save(ModuleLabelList.DefaultPath); + var inst = Main.Instance.CurrentInstance; foreach (GUIMod module in mainModList.Modules) { - mainModList.ReapplyLabels(module, Conflicts?.ContainsKey(module) ?? false, Main.Instance.CurrentInstance.Name); + mainModList.ReapplyLabels(module, Conflicts?.ContainsKey(module) ?? false, inst.Name, inst.game); } } @@ -1129,7 +1132,8 @@ private void _UpdateFilters() foreach (var row in rows) { var mod = ((GUIMod) row.Tag); - row.Visible = mainModList.IsVisible(mod, Main.Instance.CurrentInstance.Name); + var inst = Main.Instance.CurrentInstance; + row.Visible = mainModList.IsVisible(mod, inst.Name, inst.game); } ApplyHeaderGlyphs(); @@ -1221,7 +1225,7 @@ private void _UpdateModsList(Dictionary old_modules = null) Main.Instance.Wait.AddLogMessage(Properties.Resources.MainModListPopulatingList); // Update our mod listing - mainModList.ConstructModList(gui_mods, Main.Instance.CurrentInstance.Name, ChangeSet); + mainModList.ConstructModList(gui_mods, Main.Instance.CurrentInstance.Name, Main.Instance.CurrentInstance.game, ChangeSet); // C# 7.0: Executes the task and discards it _ = UpdateChangeSetAndConflicts(Main.Instance.CurrentInstance, registry); diff --git a/GUI/Controls/ModInfo.cs b/GUI/Controls/ModInfo.cs index 990e50d56f..d78c675581 100644 --- a/GUI/Controls/ModInfo.cs +++ b/GUI/Controls/ModInfo.cs @@ -156,7 +156,7 @@ private void UpdateTagsAndLabels(CkanModule mod) } } var labels = ModuleLabels?.LabelsFor(manager.CurrentInstance.Name) - .Where(l => l.ModuleIdentifiers.Contains(mod.identifier)) + .Where(l => l.ContainsModule(Main.Instance.CurrentInstance.game, mod.identifier)) .OrderBy(l => l.Name); if (labels != null) { diff --git a/GUI/Labels/ModuleLabel.cs b/GUI/Labels/ModuleLabel.cs index 6858a69113..a49aa04e13 100644 --- a/GUI/Labels/ModuleLabel.cs +++ b/GUI/Labels/ModuleLabel.cs @@ -1,7 +1,14 @@ +using System; +using System.Linq; using System.Drawing; using System.ComponentModel; +using System.Collections; using System.Collections.Generic; + using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using CKAN.Games; namespace CKAN.GUI { @@ -43,7 +50,30 @@ public class ModuleLabel public bool HoldVersion; [JsonProperty("module_identifiers_by_game", NullValueHandling = NullValueHandling.Ignore)] - public HashSet ModuleIdentifiers = new HashSet(); + [JsonConverter(typeof(JsonToGamesDictionaryConverter))] + private Dictionary> ModuleIdentifiers = + new Dictionary>(); + + /// + /// Return the number of modules associated with this label for a given game + /// + /// Game to check + /// Number of modules + public int ModuleCount(IGame game) + => ModuleIdentifiers.TryGetValue(game.ShortName, out HashSet identifiers) + ? identifiers.Count + : 0; + + /// + /// Return whether a given identifier is associated with this label for a given game + /// + /// The game to check + /// The identifier to check + /// true if this label applies to this identifier, false otherwise + public bool ContainsModule(IGame game, string identifier) + => ModuleIdentifiers.TryGetValue(game.ShortName, out HashSet identifiers) + ? identifiers.Contains(identifier) + : false; /// /// Check whether this label is active for a given game instance @@ -59,18 +89,32 @@ public bool AppliesTo(string instanceName) /// Add a module to this label's group /// /// The identifier of the module to add - public void Add(string identifier) + public void Add(IGame game, string identifier) { - ModuleIdentifiers.Add(identifier); + if (ModuleIdentifiers.TryGetValue(game.ShortName, out HashSet identifiers)) + { + identifiers.Add(identifier); + } + else + { + ModuleIdentifiers.Add(game.ShortName, new HashSet {identifier}); + } } /// /// Remove a module from this label's group /// /// The identifier of the module to remove - public void Remove(string identifier) + public void Remove(IGame game, string identifier) { - ModuleIdentifiers.Remove(identifier); + if (ModuleIdentifiers.TryGetValue(game.ShortName, out HashSet identifiers)) + { + identifiers.Remove(identifier); + if (identifiers.Count < 1) + { + ModuleIdentifiers.Remove(game.ShortName); + } + } } } diff --git a/GUI/Main/MainLabels.cs b/GUI/Main/MainLabels.cs index 89f194a126..4266ebb522 100644 --- a/GUI/Main/MainLabels.cs +++ b/GUI/Main/MainLabels.cs @@ -20,7 +20,7 @@ private void ManageMods_LabelsAfterUpdate(IEnumerable mods) var toNotif = mods .Where(m => notifLabs.Any(l => - l.ModuleIdentifiers.Contains(m.Identifier))) + l.ContainsModule(CurrentInstance.game, m.Identifier))) .Select(m => m.Name) .Memoize(); if (toNotif.Any()) @@ -39,9 +39,9 @@ private void ManageMods_LabelsAfterUpdate(IEnumerable mods) { foreach (ModuleLabel l in ManageMods.mainModList.ModuleLabels.LabelsFor(CurrentInstance.Name) .Where(l => l.RemoveOnChange - && l.ModuleIdentifiers.Contains(mod.Identifier))) + && l.ContainsModule(CurrentInstance.game, mod.Identifier))) { - l.Remove(mod.Identifier); + l.Remove(CurrentInstance.game, mod.Identifier); } } }); @@ -50,17 +50,15 @@ private void ManageMods_LabelsAfterUpdate(IEnumerable mods) private void LabelsAfterInstall(CkanModule mod) { foreach (ModuleLabel l in ManageMods.mainModList.ModuleLabels.LabelsFor(CurrentInstance.Name) - .Where(l => l.RemoveOnInstall && l.ModuleIdentifiers.Contains(mod.identifier))) + .Where(l => l.RemoveOnInstall && l.ContainsModule(CurrentInstance.game, mod.identifier))) { - l.Remove(mod.identifier); + l.Remove(CurrentInstance.game, mod.identifier); } } public bool LabelsHeld(string identifier) - { - return ManageMods.mainModList.ModuleLabels.LabelsFor(CurrentInstance.Name) - .Any(l => l.HoldVersion && l.ModuleIdentifiers.Contains(identifier)); - } + => ManageMods.mainModList.ModuleLabels.LabelsFor(CurrentInstance.Name) + .Any(l => l.HoldVersion && l.ContainsModule(CurrentInstance.game, identifier)); #endregion } diff --git a/GUI/Model/ModList.cs b/GUI/Model/ModList.cs index 469db2a9d8..a08e8c5d52 100644 --- a/GUI/Model/ModList.cs +++ b/GUI/Model/ModList.cs @@ -12,6 +12,7 @@ using CKAN.Versioning; using CKAN.Extensions; +using CKAN.Games; namespace CKAN.GUI { @@ -230,9 +231,9 @@ private IEnumerable InstalledAfterChanges( false))); } - public bool IsVisible(GUIMod mod, string instanceName) + public bool IsVisible(GUIMod mod, string instanceName, IGame game) => (activeSearches?.Any(s => s?.Matches(mod) ?? true) ?? true) - && !HiddenByTagsOrLabels(mod, instanceName); + && !HiddenByTagsOrLabels(mod, instanceName, game); private bool TagInSearches(ModuleTag tag) => activeSearches?.Any(s => s?.TagNames.Contains(tag.Name) ?? false) ?? false; @@ -240,11 +241,11 @@ private bool TagInSearches(ModuleTag tag) private bool LabelInSearches(ModuleLabel label) => activeSearches?.Any(s => s?.Labels.Contains(label) ?? false) ?? false; - private bool HiddenByTagsOrLabels(GUIMod m, string instanceName) + private bool HiddenByTagsOrLabels(GUIMod m, string instanceName, IGame game) // "Hide" labels apply to all non-custom filters => (ModuleLabels?.LabelsFor(instanceName) .Where(l => !LabelInSearches(l) && l.Hide) - .Any(l => l.ModuleIdentifiers.Contains(m.Identifier)) + .Any(l => l.ContainsModule(game, m.Identifier)) ?? false) || (ModuleTags?.Tags?.Values .Where(t => !TagInSearches(t) && t.Visible == false) @@ -265,23 +266,23 @@ public int CountModsByFilter(GUIModFilter filter) /// Changes the user has made /// The mod list public IEnumerable ConstructModList( - IEnumerable modules, string instanceName, IEnumerable mc = null) + IEnumerable modules, string instanceName, IGame game, IEnumerable mc = null) { Modules = new ReadOnlyCollection(modules.ToList()); var changes = mc?.ToList(); full_list_of_mod_rows = Modules.ToDictionary( gm => gm.Identifier, - gm => MakeRow(gm, changes, instanceName)); + gm => MakeRow(gm, changes, instanceName, game)); HasAnyInstalled = Modules.Any(m => m.IsInstalled); return full_list_of_mod_rows.Values; } - private DataGridViewRow MakeRow(GUIMod mod, List changes, string instanceName) + private DataGridViewRow MakeRow(GUIMod mod, List changes, string instanceName, IGame game) { DataGridViewRow item = new DataGridViewRow() {Tag = mod}; Color? myColor = ModuleLabels.LabelsFor(instanceName) - .FirstOrDefault(l => l.ModuleIdentifiers.Contains(mod.Identifier)) + .FirstOrDefault(l => l.ContainsModule(game, mod.Identifier)) ?.Color; if (myColor.HasValue) { @@ -380,7 +381,7 @@ public Color GetRowBackground(GUIMod mod, bool conflicted, string instanceName) => conflicted ? Color.LightCoral : full_list_of_mod_rows.ContainsKey(mod.Identifier) ? ModuleLabels.LabelsFor(instanceName) - .FirstOrDefault(l => l.ModuleIdentifiers.Contains(mod.Identifier)) + .FirstOrDefault(l => l.ContainsModule(Main.Instance.CurrentInstance.game, mod.Identifier)) ?.Color ?? Color.Empty : Color.Empty; @@ -390,13 +391,13 @@ public Color GetRowBackground(GUIMod mod, bool conflicted, string instanceName) /// after it has been added to or removed from a label group /// /// The mod that needs an update - public void ReapplyLabels(GUIMod mod, bool conflicted, string instanceName) + public void ReapplyLabels(GUIMod mod, bool conflicted, string instanceName, IGame game) { DataGridViewRow row; if (full_list_of_mod_rows.TryGetValue(mod.Identifier, out row)) { row.DefaultCellStyle.BackColor = GetRowBackground(mod, conflicted, instanceName); - row.Visible = IsVisible(mod, instanceName); + row.Visible = IsVisible(mod, instanceName, game); } } diff --git a/GUI/Model/ModSearch.cs b/GUI/Model/ModSearch.cs index bfb63532a6..3b9133bc1f 100644 --- a/GUI/Model/ModSearch.cs +++ b/GUI/Model/ModSearch.cs @@ -546,7 +546,8 @@ private bool MatchesTags(GUIMod mod) private bool MatchesLabels(GUIMod mod) // Every label in Labels must contain this mod - => Labels.Count < 1 || Labels.All(lb => lb.ModuleIdentifiers.Contains(mod.Identifier)); + => Labels.Count < 1 || Labels.All(lb => + lb.ContainsModule(Main.Instance.CurrentInstance.game, mod.Identifier)); private bool MatchesCompatible(GUIMod mod) => !Compatible.HasValue || Compatible.Value == !mod.IsIncompatible; diff --git a/Tests/GUI/Model/ModList.cs b/Tests/GUI/Model/ModList.cs index 3b092aec6b..ec9b6ac085 100644 --- a/Tests/GUI/Model/ModList.cs +++ b/Tests/GUI/Model/ModList.cs @@ -45,7 +45,8 @@ public void IsVisible_WithAllAndNoNameFilter_ReturnsTrueForCompatible() var item = new ModList(); Assert.That(item.IsVisible( new GUIMod(ckan_mod, registry, manager.CurrentInstance.VersionCriteria()), - manager.CurrentInstance.Name + manager.CurrentInstance.Name, + manager.CurrentInstance.game )); manager.Dispose(); @@ -89,7 +90,8 @@ public void ConstructModList_NumberOfRows_IsEqualToNumberOfMods() new GUIMod(TestData.FireSpitterModule(), registry, manager.CurrentInstance.VersionCriteria()), new GUIMod(TestData.kOS_014_module(), registry, manager.CurrentInstance.VersionCriteria()) }, - manager.CurrentInstance.Name + manager.CurrentInstance.Name, + manager.CurrentInstance.game ); Assert.That(mod_list, Has.Count.EqualTo(2)); @@ -187,7 +189,7 @@ public void InstallAndSortByCompat_WithAnyCompat_NoCrash() .Select(mod => new GUIMod(mod.Value.Latest(), registry, instance.KSP.VersionCriteria())) .ToList(); - listGui.Rows.AddRange(modList.ConstructModList(modules, null).ToArray()); + listGui.Rows.AddRange(modList.ConstructModList(modules, null, instance.KSP.game).ToArray()); // The header row adds one to the count Assert.AreEqual(modules.Count + 1, listGui.Rows.Count);