diff --git a/CHANGELOG.md b/CHANGELOG.md index 89caee7765..7764f2f7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to this project will be documented in this file. - [CLI] Correctly print cmdline errors with braces (#3880 by: HebaruSan; reviewed: techman83) - [Multiple] Caching and changeset fixes (#3881 by: HebaruSan; reviewed: techman83) - [GUI] Mod list fixes and improvements (#3883 by: HebaruSan; reviewed: techman83) +- [Multiple] Multi-game labels (#3885 by: HebaruSan; reviewed: techman83) ### Internal diff --git a/ConsoleUI/ExitScreen.cs b/ConsoleUI/ExitScreen.cs index 2dead81959..86562f89d1 100644 --- a/ConsoleUI/ExitScreen.cs +++ b/ConsoleUI/ExitScreen.cs @@ -43,12 +43,12 @@ private void Draw(ConsoleTheme theme) } else { - Console.ResetColor(); + Console.ResetColor(); } Console.Clear(); // Specially formatted snippets - var ckanPiece = new FancyLinePiece("CKAN", theme.ExitInnerBg, theme.ExitHighlightFg); + var ckanPiece = new FancyLinePiece(Meta.GetProductName(), theme.ExitInnerBg, theme.ExitHighlightFg); var ckanVersionPiece = new FancyLinePiece($"CKAN {Meta.GetVersion()}", theme.ExitInnerBg, theme.ExitHighlightFg); var releaseLinkPiece = new FancyLinePiece("https://github.com/KSP-CKAN/CKAN/releases/latest", theme.ExitInnerBg, theme.ExitLinkFg); var issuesLinkPiece = new FancyLinePiece("https://github.com/KSP-CKAN/CKAN/issues", theme.ExitInnerBg, theme.ExitLinkFg); diff --git a/Core/CKANPathUtils.cs b/Core/CKANPathUtils.cs index 193b4aa65e..d0835a6b3e 100644 --- a/Core/CKANPathUtils.cs +++ b/Core/CKANPathUtils.cs @@ -9,6 +9,13 @@ namespace CKAN { public static class CKANPathUtils { + /// + /// Path to save CKAN data shared across all game instances + /// + public static readonly string AppDataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + Meta.GetProductName()); + private static readonly ILog log = LogManager.GetLogger(typeof(CKANPathUtils)); /// diff --git a/Core/CompatibleGameVersions.cs b/Core/CompatibleGameVersions.cs index c54e0a92a7..ae5744dbb4 100644 --- a/Core/CompatibleGameVersions.cs +++ b/Core/CompatibleGameVersions.cs @@ -14,15 +14,10 @@ class CompatibleGameVersions public class CompatibleGameVersionsConverter : JsonPropertyNamesChangedConverter { protected override Dictionary mapping - { - get + => new Dictionary { - return new Dictionary - { - { "VersionOfKspWhenWritten", "GameVersionWhenWritten" }, - { "CompatibleKspVersions", "Versions" } - }; - } - } + { "VersionOfKspWhenWritten", "GameVersionWhenWritten" }, + { "CompatibleKspVersions", "Versions" } + }; } } diff --git a/Core/Configuration/JsonConfiguration.cs b/Core/Configuration/JsonConfiguration.cs index 7a78debf98..fae03c1a46 100644 --- a/Core/Configuration/JsonConfiguration.cs +++ b/Core/Configuration/JsonConfiguration.cs @@ -51,17 +51,10 @@ private class GameInstanceEntry // CKAN_CONFIG_FILE environment variable. public static readonly string defaultConfigFile = Environment.GetEnvironmentVariable("CKAN_CONFIG_FILE") - ?? Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "CKAN", - "config.json" - ); - - public static readonly string DefaultDownloadCacheDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "CKAN", - "downloads" - ); + ?? Path.Combine(CKANPathUtils.AppDataPath, "config.json"); + + public static readonly string DefaultDownloadCacheDir = + Path.Combine(CKANPathUtils.AppDataPath, "downloads"); // The actual config file state, and its location on the disk (we allow // the location to be changed for unit tests). Note that these are static diff --git a/Core/Configuration/Win32RegistryConfiguration.cs b/Core/Configuration/Win32RegistryConfiguration.cs index 2978020f59..6f35891291 100644 --- a/Core/Configuration/Win32RegistryConfiguration.cs +++ b/Core/Configuration/Win32RegistryConfiguration.cs @@ -21,11 +21,8 @@ public class Win32RegistryConfiguration : IConfiguration private const string authTokenKey = CKAN_KEY + @"\AuthTokens"; private static readonly string authTokenKeyNoPrefix = StripPrefixKey(authTokenKey); - private static readonly string defaultDownloadCacheDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "CKAN", - "downloads" - ); + private static readonly string defaultDownloadCacheDir = + Path.Combine(CKANPathUtils.AppDataPath, "downloads"); public string DownloadCacheDir { diff --git a/Core/Converters/JsonPropertyNamesChangedConverter.cs b/Core/Converters/JsonPropertyNamesChangedConverter.cs index ec1a0c5240..a9e125567b 100644 --- a/Core/Converters/JsonPropertyNamesChangedConverter.cs +++ b/Core/Converters/JsonPropertyNamesChangedConverter.cs @@ -2,28 +2,53 @@ using System.Linq; using System.Reflection; using System.Collections.Generic; + using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace CKAN { + /// + /// Base class for a class-level converter that transfers values from + /// the old name for a property to its new name. + /// Inherit, then override the mapping property to specify the renamings. + /// public abstract class JsonPropertyNamesChangedConverter : JsonConverter { + /// + /// 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(); } - public override bool CanConvert(Type objectType) - { - return objectType.GetTypeInfo().IsClass; - } + /// + /// We only want to convert classes, not properties + /// + /// Type where this class been used as a JsonConverter + /// true if it's a class, false otherwise + public override bool CanConvert(Type objectType) => objectType.GetTypeInfo().IsClass; + /// + /// Parse JSON to an object, renaming properties according to the mapping property + /// + /// Object that provides tokens to be translated + /// The output type to be populated + /// Not used + /// Generates output objects from tokens + /// Class object populated according to the renaming scheme public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { object instance = Activator.CreateInstance(objectType); - var props = objectType.GetTypeInfo().DeclaredProperties.ToList(); JObject jo = JObject.Load(reader); var changes = mapping; foreach (JProperty jp in jo.Properties()) @@ -33,15 +58,48 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist { name = jp.Name; } - PropertyInfo prop = props.FirstOrDefault(pi => pi.CanWrite && ( - pi.GetCustomAttribute()?.PropertyName == name - || pi.Name == name)); - prop?.SetValue(instance, jp.Value.ToObject(prop.PropertyType, serializer)); + PropertyInfo prop = objectType.GetTypeInfo().DeclaredProperties.FirstOrDefault(pi => + pi.CanWrite + && (pi.GetCustomAttribute()?.PropertyName ?? pi.Name) == name); + if (prop != null) + { + prop.SetValue(instance, + GetValue(prop.GetCustomAttribute(), + jp.Value, prop.PropertyType, serializer)); + } + else + { + // No property, maybe there's a field + FieldInfo field = objectType.GetTypeInfo().DeclaredFields.FirstOrDefault(fi => + (fi.GetCustomAttribute()?.PropertyName ?? fi.Name) == name); + if (field != null) + { + field.SetValue(instance, + GetValue(field.GetCustomAttribute(), + jp.Value, field.FieldType, serializer)); + } + } } return instance; } - // This is what you need to override in the child class + private static object GetValue(JsonConverterAttribute attrib, + JToken value, Type outputType, JsonSerializer serializer) + => attrib != null ? ApplyConverter((JsonConverter)Activator.CreateInstance(attrib.ConverterType, + attrib.ConverterParameters), + value, outputType, serializer) + : value.ToObject(outputType, serializer); + + private static object ApplyConverter(JsonConverter converter, + JToken value, Type outputType, JsonSerializer serializer) + => converter.CanRead ? converter.ReadJson(new JTokenReader(value), + outputType, null, serializer) + : value.ToObject(outputType, serializer); + + /// + /// This is what you need to override in your child class + /// + /// Mapping from old names to new names protected abstract Dictionary mapping { get; diff --git a/Core/Converters/JsonSingleOrArrayConverter.cs b/Core/Converters/JsonSingleOrArrayConverter.cs index 5db5b29db2..7f760ed1ee 100644 --- a/Core/Converters/JsonSingleOrArrayConverter.cs +++ b/Core/Converters/JsonSingleOrArrayConverter.cs @@ -41,9 +41,6 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s /// /// false /// - public override bool CanConvert(Type object_type) - { - return false; - } + public override bool CanConvert(Type object_type) => false; } } 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 c95b4b3f6e..67bf3a317a 100644 --- a/Core/GameInstanceManager.cs +++ b/Core/GameInstanceManager.cs @@ -612,9 +612,7 @@ public void Dispose() } public static bool IsGameInstanceDir(DirectoryInfo path) - { - return knownGames.Any(g => g.GameInFolder(path)); - } + => knownGames.Any(g => g.GameInFolder(path)); /// /// Tries to determine the game that is installed at the given path @@ -643,8 +641,20 @@ public IGame DetermineGame(DirectoryInfo path, IUser user) } } + /// + /// Return a game object based on its short name + /// + /// The short name to find + /// A game object or null if none found 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/Core/Games/KerbalSpaceProgram/GameVersionProviders/KspBuildMap.cs b/Core/Games/KerbalSpaceProgram/GameVersionProviders/KspBuildMap.cs index 6da7278ad1..37f634c6db 100644 --- a/Core/Games/KerbalSpaceProgram/GameVersionProviders/KspBuildMap.cs +++ b/Core/Games/KerbalSpaceProgram/GameVersionProviders/KspBuildMap.cs @@ -33,9 +33,8 @@ public sealed class KspBuildMap : IKspBuildMap // TODO: Need a way for the client to configure this private static readonly Uri BuildMapUri = new Uri("https://raw.githubusercontent.com/KSP-CKAN/CKAN-meta/master/builds.json"); - private static readonly string cachedBuildMapPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "CKAN", "builds-ksp.json"); + private static readonly string cachedBuildMapPath = + Path.Combine(CKANPathUtils.AppDataPath, "builds-ksp.json"); private static readonly ILog Log = LogManager.GetLogger(typeof(KspBuildMap)); diff --git a/Core/Games/KerbalSpaceProgram2.cs b/Core/Games/KerbalSpaceProgram2.cs index fe699f7423..af1ac89c4b 100644 --- a/Core/Games/KerbalSpaceProgram2.cs +++ b/Core/Games/KerbalSpaceProgram2.cs @@ -151,9 +151,8 @@ public string[] AdjustCommandLine(string[] args, GameVersion installedVersion) private static readonly Uri BuildMapUri = new Uri("https://raw.githubusercontent.com/KSP-CKAN/KSP2-CKAN-meta/main/builds.json"); - private static readonly string cachedBuildMapPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "CKAN", "builds-ksp2.json"); + private static readonly string cachedBuildMapPath = + Path.Combine(CKANPathUtils.AppDataPath, "builds-ksp2.json"); private List versions = JsonConvert.DeserializeObject>( File.Exists(cachedBuildMapPath) diff --git a/Core/Meta.cs b/Core/Meta.cs index 0c3246ca47..c4d8a620d8 100644 --- a/Core/Meta.cs +++ b/Core/Meta.cs @@ -1,17 +1,27 @@ using System; +using System.Linq; using System.Reflection; namespace CKAN { public static class Meta { + /// + /// Programmatically generate the string "CKAN" from the assembly info attributes, + /// so we don't have to embed that string in many places + /// + /// "CKAN" + public static string GetProductName() + => Assembly.GetExecutingAssembly() + .GetAssemblyAttribute() + .Product; + public static string GetVersion(VersionFormat format = VersionFormat.Normal) { - var version = ((AssemblyInformationalVersionAttribute) - Assembly - .GetExecutingAssembly() - .GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false)[0] - ).InformationalVersion; + var version = Assembly + .GetExecutingAssembly() + .GetAssemblyAttribute() + .InformationalVersion; var dashIndex = version.IndexOf('-'); var plusIndex = version.IndexOf('+'); @@ -38,5 +48,9 @@ public static string GetVersion(VersionFormat format = VersionFormat.Normal) return "v" + version; } + + private static T GetAssemblyAttribute(this Assembly assembly) + => (T)assembly.GetCustomAttributes(typeof(T), false) + .First(); } } diff --git a/Core/Registry/Tags/ModuleTagList.cs b/Core/Registry/Tags/ModuleTagList.cs index b4d0993e52..12ae01e5c4 100644 --- a/Core/Registry/Tags/ModuleTagList.cs +++ b/Core/Registry/Tags/ModuleTagList.cs @@ -17,11 +17,8 @@ public class ModuleTagList [JsonProperty("hidden_tags")] public HashSet HiddenTags = new HashSet(); - public static readonly string DefaultPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "CKAN", - "tags.json" - ); + public static readonly string DefaultPath = + Path.Combine(CKANPathUtils.AppDataPath, "tags.json"); public void BuildTagIndexFor(AvailableModule am) { @@ -51,7 +48,7 @@ public void BuildTagIndexFor(AvailableModule am) Untagged.Add(am.AllAvailable().First().identifier); } } - + public static ModuleTagList Load(string path) { try 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 3392ff6d72..a49aa04e13 100644 --- a/GUI/Labels/ModuleLabel.cs +++ b/GUI/Labels/ModuleLabel.cs @@ -1,11 +1,19 @@ +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 { [JsonObject(MemberSerialization.OptIn)] + [JsonConverter(typeof(ModuleIdentifiersRenamedConverter))] public class ModuleLabel { [JsonProperty("name", NullValueHandling = NullValueHandling.Ignore)] @@ -41,8 +49,31 @@ public class ModuleLabel [DefaultValue(false)] public bool HoldVersion; - [JsonProperty("module_identifiers", NullValueHandling = NullValueHandling.Ignore)] - public HashSet ModuleIdentifiers = new HashSet(); + [JsonProperty("module_identifiers_by_game", NullValueHandling = NullValueHandling.Ignore)] + [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 @@ -52,26 +83,50 @@ public class ModuleLabel /// True if active, false otherwise /// public bool AppliesTo(string instanceName) - { - return InstanceName == null || InstanceName == instanceName; - } + => InstanceName == null || InstanceName == 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); + } + } } } + + /// + /// Protect old clients from trying to load a file they can't parse + /// + public class ModuleIdentifiersRenamedConverter : JsonPropertyNamesChangedConverter + { + protected override Dictionary mapping + => new Dictionary + { + { "module_identifiers", "module_identifiers_by_game" } + }; + } } diff --git a/GUI/Labels/ModuleLabelList.cs b/GUI/Labels/ModuleLabelList.cs index f58e542c5c..52357efc88 100644 --- a/GUI/Labels/ModuleLabelList.cs +++ b/GUI/Labels/ModuleLabelList.cs @@ -4,6 +4,7 @@ using System.IO; using System.Drawing; using System.Runtime.Serialization; + using Newtonsoft.Json; namespace CKAN.GUI @@ -15,19 +16,13 @@ public class ModuleLabelList public ModuleLabel[] Labels = new ModuleLabel[] {}; public IEnumerable LabelsFor(string instanceName) - { - return Labels.Where(l => l.AppliesTo(instanceName)); - } + => Labels.Where(l => l.AppliesTo(instanceName)); - public static readonly string DefaultPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "CKAN", - "labels.json" - ); + public static readonly string DefaultPath = + Path.Combine(CKANPathUtils.AppDataPath, "labels.json"); public static ModuleLabelList GetDefaultLabels() - { - return new ModuleLabelList() + => new ModuleLabelList() { Labels = new ModuleLabel[] { @@ -50,7 +45,6 @@ public static ModuleLabelList GetDefaultLabels() } } }; - } public static ModuleLabelList Load(string path) { diff --git a/GUI/Main/Main.cs b/GUI/Main/Main.cs index 86fbf4ad27..87c20b59a9 100644 --- a/GUI/Main/Main.cs +++ b/GUI/Main/Main.cs @@ -113,7 +113,7 @@ public Main(string[] cmdlineArgs, GameInstanceManager mgr) { Interval = 2 }; - timer.Tick += (sender, e) => Thread.Yield();; + timer.Tick += (sender, e) => Thread.Yield(); timer.Start(); } 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);