From d8bbf3c4412514e20abea6181adaa567a82db5a2 Mon Sep 17 00:00:00 2001 From: ChulkyBow <83290351+ChulkyBow@users.noreply.github.com> Date: Tue, 18 Jan 2022 16:25:36 +0200 Subject: Update Ukrainian translation for SMAPI --- src/SMAPI/i18n/uk.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/SMAPI/i18n/uk.json b/src/SMAPI/i18n/uk.json index d84aabcf..a4286363 100644 --- a/src/SMAPI/i18n/uk.json +++ b/src/SMAPI/i18n/uk.json @@ -1,6 +1,6 @@ { // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) - "generic.date": "{{season}} День {{day}}", - "generic.date-with-year": "{{season}} День {{day}}, Рік {{year}}" + "generic.date": "{{season}}, {{day}}-й день", + "generic.date-with-year": "{{season}}, {{day}}-й день, {{year}} рік" } -- cgit From bca9e599cc02edcc6e9bcfba2138d916ae6a3a79 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 29 Jan 2022 17:27:42 -0500 Subject: remove unneeded dictionary patch The dictionary errors were improved in .NET 5, so they include the key automatically. --- src/SMAPI.Mods.ErrorHandler/ModEntry.cs | 1 - .../Patches/DictionaryPatcher.cs | 98 ---------------------- 2 files changed, 99 deletions(-) delete mode 100644 src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs (limited to 'src') diff --git a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs index 067f6a8d..7286e316 100644 --- a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs +++ b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs @@ -30,7 +30,6 @@ namespace StardewModdingAPI.Mods.ErrorHandler // apply patches HarmonyPatcher.Apply(this.ModManifest.UniqueID, this.Monitor, new DialoguePatcher(monitorForGame, this.Helper.Reflection), - new DictionaryPatcher(this.Helper.Reflection), new EventPatcher(monitorForGame), new GameLocationPatcher(monitorForGame), new IClickableMenuPatcher(), diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs deleted file mode 100644 index 8ceafcc5..00000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using StardewModdingAPI.Internal.Patching; -using StardewValley.GameData; -using StardewValley.GameData.HomeRenovations; -using StardewValley.GameData.Movies; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// Harmony patches for which add the accessed key to exceptions. - /// Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments. - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class DictionaryPatcher : BasePatcher - { - /********* - ** Fields - *********/ - /// Simplifies access to private code. - private static IReflectionHelper Reflection; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// Simplifies access to private code. - public DictionaryPatcher(IReflectionHelper reflector) - { - DictionaryPatcher.Reflection = reflector; - } - - /// - public override void Apply(Harmony harmony, IMonitor monitor) - { - Type[] keyTypes = { typeof(int), typeof(string) }; - Type[] valueTypes = { typeof(int), typeof(string), typeof(HomeRenovation), typeof(MovieData), typeof(SpecialOrderData) }; - - foreach (Type keyType in keyTypes) - { - foreach (Type valueType in valueTypes) - { - Type dictionaryType = typeof(Dictionary<,>).MakeGenericType(keyType, valueType); - - harmony.Patch( - original: AccessTools.Method(dictionaryType, "get_Item") ?? throw new InvalidOperationException($"Can't find method {PatchHelper.GetMethodString(dictionaryType, "get_Item")} to patch."), - finalizer: this.GetHarmonyMethod(nameof(DictionaryPatcher.Finalize_GetItem)) - ); - - harmony.Patch( - original: AccessTools.Method(dictionaryType, "Add") ?? throw new InvalidOperationException($"Can't find method {PatchHelper.GetMethodString(dictionaryType, "Add")} to patch."), - finalizer: this.GetHarmonyMethod(nameof(DictionaryPatcher.Finalize_Add)) - ); - } - } - } - - - /********* - ** Private methods - *********/ - /// The method to call after the dictionary indexer throws an exception. - /// The dictionary key being fetched. - /// The exception thrown by the wrapped method, if any. - /// Returns the exception to throw, if any. - private static Exception Finalize_GetItem(object key, Exception __exception) - { - if (__exception is KeyNotFoundException) - DictionaryPatcher.AddKey(__exception, key); - - return __exception; - } - - /// The method to call after a dictionary insert throws an exception. - /// The dictionary key being inserted. - /// The exception thrown by the wrapped method, if any. - /// Returns the exception to throw, if any. - private static Exception Finalize_Add(object key, Exception __exception) - { - if (__exception is ArgumentException) - DictionaryPatcher.AddKey(__exception, key); - - return __exception; - } - - /// Add the dictionary key to an exception message. - /// The exception whose message to edit. - /// The dictionary key. - private static void AddKey(Exception exception, object key) - { - DictionaryPatcher.Reflection - .GetField(exception, "_message") - .SetValue($"{exception.Message}\nkey: '{key}'"); - } - } -} -- cgit From 6b9c9be2b6f08f46077f8d81ef05e9d249d72935 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 29 Jan 2022 17:33:12 -0500 Subject: move item repo secret note + flavored object logic into methods --- .../Framework/ItemRepository.cs | 248 +++++++++++---------- 1 file changed, 133 insertions(+), 115 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs index 0357fe6b..4a57ba5a 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -139,15 +139,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework { if (ShouldGet(ItemType.Object)) { - foreach (int secretNoteId in this.TryLoad("Data\\SecretNotes").Keys) - { - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, _ => - { - SObject note = new SObject(79, 1); - note.name = $"{note.name} #{secretNoteId}"; - return note; - }); - } + foreach (SearchableItem secretNote in this.GetSecretNotes()) + yield return secretNote; } } @@ -176,112 +169,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework // flavored items if (includeVariants) { - switch (item.Category) - { - // fruit products - case SObject.FruitsCategory: - // wine - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + item.ParentSheetIndex, _ => new SObject(348, 1) - { - Name = $"{item.Name} Wine", - Price = item.Price * 3, - preserve = { SObject.PreserveType.Wine }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - - // jelly - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + item.ParentSheetIndex, _ => new SObject(344, 1) - { - Name = $"{item.Name} Jelly", - Price = 50 + item.Price * 2, - preserve = { SObject.PreserveType.Jelly }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - break; - - // vegetable products - case SObject.VegetableCategory: - // juice - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + item.ParentSheetIndex, _ => new SObject(350, 1) - { - Name = $"{item.Name} Juice", - Price = (int)(item.Price * 2.25d), - preserve = { SObject.PreserveType.Juice }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - - // pickled - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + item.ParentSheetIndex, _ => new SObject(342, 1) - { - Name = $"Pickled {item.Name}", - Price = 50 + item.Price * 2, - preserve = { SObject.PreserveType.Pickle }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - break; - - // flower honey - case SObject.flowersCategory: - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + item.ParentSheetIndex, _ => - { - SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false) - { - Name = $"{item.Name} Honey", - preservedParentSheetIndex = { item.ParentSheetIndex } - }; - honey.Price += item.Price * 2; - return honey; - }); - break; - - // roe and aged roe (derived from FishPond.GetFishProduce) - case SObject.sellAtFishShopCategory when item.ParentSheetIndex == 812: - { - this.GetRoeContextTagLookups(out HashSet simpleTags, out List> complexTags); - - foreach (var pair in Game1.objectInformation) - { - // get input - SObject input = this.TryCreate(ItemType.Object, pair.Key, p => new SObject(p.ID, 1))?.Item as SObject; - var inputTags = input?.GetContextTags(); - if (inputTags?.Any() != true) - continue; - - // check if roe-producing fish - if (!inputTags.Any(tag => simpleTags.Contains(tag)) && !complexTags.Any(set => set.All(tag => input.HasContextTag(tag)))) - continue; - - // yield roe - SObject roe = null; - Color color = this.GetRoeColor(input); - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + item.ParentSheetIndex, _ => - { - roe = new ColoredObject(812, 1, color) - { - name = $"{input.Name} Roe", - preserve = { Value = SObject.PreserveType.Roe }, - preservedParentSheetIndex = { Value = input.ParentSheetIndex } - }; - roe.Price += input.Price / 2; - return roe; - }); - - // aged roe - if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item - { - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + item.ParentSheetIndex, _ => new ColoredObject(447, 1, color) - { - name = $"Aged {input.Name} Roe", - Category = -27, - preserve = { Value = SObject.PreserveType.AgedRoe }, - preservedParentSheetIndex = { Value = input.ParentSheetIndex }, - Price = roe.Price * 2 - }); - } - } - } - break; - } + foreach (SearchableItem variant in this.GetFlavoredObjectVariants(item)) + yield return variant; } } } @@ -295,6 +184,135 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework /********* ** Private methods *********/ + /// Get the individual secret note items, if any. + /// Derived from . + private IEnumerable GetSecretNotes() + { + foreach (int secretNoteId in this.TryLoad("Data\\SecretNotes").Keys) + { + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, _ => + { + SObject note = new(79, 1); + note.name = $"{note.name} #{secretNoteId}"; + return note; + }); + } + } + + /// Get flavored variants of a base item (like Blueberry Wine for Blueberry), if any. + /// A sample of the base item. + private IEnumerable GetFlavoredObjectVariants(SObject item) + { + int id = item.ParentSheetIndex; + + switch (item.Category) + { + // fruit products + case SObject.FruitsCategory: + // wine + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, _ => new SObject(348, 1) + { + Name = $"{item.Name} Wine", + Price = item.Price * 3, + preserve = { SObject.PreserveType.Wine }, + preservedParentSheetIndex = { id } + }); + + // jelly + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, _ => new SObject(344, 1) + { + Name = $"{item.Name} Jelly", + Price = 50 + item.Price * 2, + preserve = { SObject.PreserveType.Jelly }, + preservedParentSheetIndex = { id } + }); + break; + + // vegetable products + case SObject.VegetableCategory: + // juice + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, _ => new SObject(350, 1) + { + Name = $"{item.Name} Juice", + Price = (int)(item.Price * 2.25d), + preserve = { SObject.PreserveType.Juice }, + preservedParentSheetIndex = { id } + }); + + // pickled + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, _ => new SObject(342, 1) + { + Name = $"Pickled {item.Name}", + Price = 50 + item.Price * 2, + preserve = { SObject.PreserveType.Pickle }, + preservedParentSheetIndex = { id } + }); + break; + + // flower honey + case SObject.flowersCategory: + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, _ => + { + SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false) + { + Name = $"{item.Name} Honey", + preservedParentSheetIndex = { id } + }; + honey.Price += item.Price * 2; + return honey; + }); + break; + + // roe and aged roe (derived from FishPond.GetFishProduce) + case SObject.sellAtFishShopCategory when id == 812: + { + this.GetRoeContextTagLookups(out HashSet simpleTags, out List> complexTags); + + foreach (var pair in Game1.objectInformation) + { + // get input + SObject input = this.TryCreate(ItemType.Object, pair.Key, p => new SObject(p.ID, 1))?.Item as SObject; + var inputTags = input?.GetContextTags(); + if (inputTags?.Any() != true) + continue; + + // check if roe-producing fish + if (!inputTags.Any(tag => simpleTags.Contains(tag)) && !complexTags.Any(set => set.All(tag => input.HasContextTag(tag)))) + continue; + + // yield roe + SObject roe = null; + Color color = this.GetRoeColor(input); + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, _ => + { + roe = new ColoredObject(812, 1, color) + { + name = $"{input.Name} Roe", + preserve = { Value = SObject.PreserveType.Roe }, + preservedParentSheetIndex = { Value = input.ParentSheetIndex } + }; + roe.Price += input.Price / 2; + return roe; + }); + + // aged roe + if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item + { + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, _ => new ColoredObject(447, 1, color) + { + name = $"Aged {input.Name} Roe", + Category = -27, + preserve = { Value = SObject.PreserveType.AgedRoe }, + preservedParentSheetIndex = { Value = input.ParentSheetIndex }, + Price = roe.Price * 2 + }); + } + } + } + break; + } + } + /// Get optimized lookups to match items which produce roe in a fish pond. /// A lookup of simple singular tags which match a roe-producing fish. /// A list of tag sets which match roe-producing fish. -- cgit From 6dd4a8a12b25d349b18609132dade14da6ec3cf9 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 29 Jan 2022 17:46:45 -0500 Subject: fix item repo's handling of Journal Scraps and Secret Notes --- docs/release-notes.md | 6 ++- .../Framework/ItemRepository.cs | 62 ++++++++++++++++------ 2 files changed, 52 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/docs/release-notes.md b/docs/release-notes.md index f45df4bd..f1911716 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -2,7 +2,11 @@ # Release notes ## Upcoming release -* Improved translations. Thanks to ChulkyBow (updated Ukrainian)! +* For players: + * Improved translations. Thanks to ChulkyBow (updated Ukrainian)! + +* For console commands: + * Fixed `player_add` with Journal Scraps and Secret Notes. ## 3.13.4 Released 16 January 2022 for Stardew Valley 1.5.6 or later. diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs index 4a57ba5a..8704a403 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -134,24 +134,34 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework { string[] fields = Game1.objectInformation[id]?.Split('/'); - // secret notes - if (id == 79) + // ring + if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring + { + if (ShouldGet(ItemType.Ring)) + yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID)); + } + + // journal scrap + else if (id == 842) { if (ShouldGet(ItemType.Object)) { - foreach (SearchableItem secretNote in this.GetSecretNotes()) - yield return secretNote; + foreach (SearchableItem journalScrap in this.GetSecretNotes(isJournalScrap: true)) + yield return journalScrap; } } - // ring - else if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring + // secret notes + else if (id == 79) { - if (ShouldGet(ItemType.Ring)) - yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID)); + if (ShouldGet(ItemType.Object)) + { + foreach (SearchableItem secretNote in this.GetSecretNotes(isJournalScrap: false)) + yield return secretNote; + } } - // item + // object else if (ShouldGet(ItemType.Object)) { // spawn main item @@ -184,16 +194,38 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework /********* ** Private methods *********/ - /// Get the individual secret note items, if any. + /// Get the individual secret note or journal scrap items. + /// Whether to get journal scraps. /// Derived from . - private IEnumerable GetSecretNotes() + private IEnumerable GetSecretNotes(bool isJournalScrap) { - foreach (int secretNoteId in this.TryLoad("Data\\SecretNotes").Keys) + // get base item ID + int baseId = isJournalScrap ? 842 : 79; + + // get secret note IDs + var ids = this + .TryLoad("Data\\SecretNotes") + .Keys + .Where(isJournalScrap + ? id => (id >= GameLocation.JOURNAL_INDEX) + : id => (id < GameLocation.JOURNAL_INDEX) + ) + .Select(isJournalScrap + ? id => (id - GameLocation.JOURNAL_INDEX) + : id => id + ); + + // build items + foreach (int id in ids) { - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, _ => + int fakeId = this.CustomIDOffset * 8 + id; + if (isJournalScrap) + fakeId += GameLocation.JOURNAL_INDEX; + + yield return this.TryCreate(ItemType.Object, fakeId, _ => { - SObject note = new(79, 1); - note.name = $"{note.name} #{secretNoteId}"; + SObject note = new(baseId, 1); + note.Name = $"{note.Name} #{id}"; return note; }); } -- cgit From 3431f486a2ef93e86d8923c1a4651644110df81b Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 29 Jan 2022 18:15:42 -0500 Subject: normalize season names in SDate constructor --- docs/release-notes.md | 3 +++ src/SMAPI.Tests/Utilities/SDateTests.cs | 10 ++++++---- src/SMAPI/Utilities/SDate.cs | 4 +++- 3 files changed, 12 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/docs/release-notes.md b/docs/release-notes.md index f1911716..4ca11f01 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -5,6 +5,9 @@ * For players: * Improved translations. Thanks to ChulkyBow (updated Ukrainian)! +* For mod authors: + * The `SDate` constructor is no longer case-sensitive for season names. + * For console commands: * Fixed `player_add` with Journal Scraps and Secret Notes. diff --git a/src/SMAPI.Tests/Utilities/SDateTests.cs b/src/SMAPI.Tests/Utilities/SDateTests.cs index 0461952e..374f4921 100644 --- a/src/SMAPI.Tests/Utilities/SDateTests.cs +++ b/src/SMAPI.Tests/Utilities/SDateTests.cs @@ -16,9 +16,12 @@ namespace SMAPI.Tests.Utilities /********* ** Fields *********/ - /// All valid seasons. + /// The valid seasons. private static readonly string[] ValidSeasons = { "spring", "summer", "fall", "winter" }; + /// Sample user inputs for season names. + private static readonly string[] SampleSeasonValues = SDateTests.ValidSeasons.Concat(new[] { " WIntEr " }).ToArray(); + /// All valid days of a month. private static readonly int[] ValidDays = Enumerable.Range(1, 28).ToArray(); @@ -55,19 +58,18 @@ namespace SMAPI.Tests.Utilities ** Constructor ****/ [Test(Description = "Assert that the constructor sets the expected values for all valid dates.")] - public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.ValidSeasons))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year) + public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.SampleSeasonValues))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year) { // act SDate date = new SDate(day, season, year); // assert Assert.AreEqual(day, date.Day); - Assert.AreEqual(season, date.Season); + Assert.AreEqual(season.Trim().ToLowerInvariant(), date.Season); Assert.AreEqual(year, date.Year); } [Test(Description = "Assert that the constructor throws an exception if the values are invalid.")] - [TestCase(01, "Spring", 1)] // seasons are case-sensitive [TestCase(01, "springs", 1)] // invalid season name [TestCase(-1, "spring", 1)] // day < 0 [TestCase(0, "spring", 1)] // day zero diff --git a/src/SMAPI/Utilities/SDate.cs b/src/SMAPI/Utilities/SDate.cs index cd075dcc..e10a59f8 100644 --- a/src/SMAPI/Utilities/SDate.cs +++ b/src/SMAPI/Utilities/SDate.cs @@ -250,6 +250,8 @@ namespace StardewModdingAPI.Utilities /// One of the arguments has an invalid value (like day 35). private SDate(int day, string season, int year, bool allowDayZero) { + season = season?.Trim().ToLowerInvariant(); + // validate if (season == null) throw new ArgumentNullException(nameof(season)); @@ -277,7 +279,7 @@ namespace StardewModdingAPI.Utilities /// The year. private bool IsDayZero(int day, string season, int year) { - return day == 0 && season == "spring" && year == 1; + return day == 0 && season?.Trim().ToLower() == "spring" && year == 1; } /// Get the day of week for a given date. -- cgit From 25a9f54ecfdaf6c4ad67dfb46c3f8c556d15d949 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 29 Jan 2022 20:43:45 -0500 Subject: fix manifest JSON schema's update key pattern --- docs/release-notes.md | 3 +++ src/SMAPI.Web/wwwroot/schemas/manifest.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/docs/release-notes.md b/docs/release-notes.md index 4ca11f01..585644ef 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -11,6 +11,9 @@ * For console commands: * Fixed `player_add` with Journal Scraps and Secret Notes. +* For the web UI: + * Fixed JSON validator warning for update keys without a subkey. + ## 3.13.4 Released 16 January 2022 for Stardew Valley 1.5.6 or later. diff --git a/src/SMAPI.Web/wwwroot/schemas/manifest.json b/src/SMAPI.Web/wwwroot/schemas/manifest.json index b6722347..7457b993 100644 --- a/src/SMAPI.Web/wwwroot/schemas/manifest.json +++ b/src/SMAPI.Web/wwwroot/schemas/manifest.json @@ -103,7 +103,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?i)(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_\\-]+/[A-Za-z0-9_\\-]+|ModDrop:\\d+)(?: *@ *[a-zA-Z0-9_]+ *)$", + "pattern": "^(?i)(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_\\-]+/[A-Za-z0-9_\\-]+|ModDrop:\\d+)(?: *@ *[a-zA-Z0-9_]+ *)?$", "@errorMessages": { "pattern": "Invalid update key; see https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info." } -- cgit From 0ff82c38e7a5b630256d2cd23a63ac1088d13e39 Mon Sep 17 00:00:00 2001 From: Shockah Date: Tue, 8 Feb 2022 20:02:13 +0100 Subject: allow default interface method implementations in API proxies --- .../Framework/Reflection/InterfaceProxyBuilder.cs | 31 +++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 70ef81f8..164cac0b 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -55,10 +55,39 @@ namespace StardewModdingAPI.Framework.Reflection il.Emit(OpCodes.Ret); } + var allTargetMethods = targetType.GetMethods().ToList(); + foreach (Type targetInterface in targetType.GetInterfaces()) + { + foreach (MethodInfo targetMethod in targetInterface.GetMethods()) + { + if (!targetMethod.IsAbstract) + allTargetMethods.Add(targetMethod); + } + } + // proxy methods foreach (MethodInfo proxyMethod in interfaceType.GetMethods()) { - var targetMethod = targetType.GetMethod(proxyMethod.Name, proxyMethod.GetParameters().Select(a => a.ParameterType).ToArray()); + var proxyMethodParameters = proxyMethod.GetParameters(); + var targetMethod = allTargetMethods.Where(m => + { + if (m.Name != proxyMethod.Name) + return false; + if (m.ReturnType != proxyMethod.ReturnType) + return false; + + var mParameters = m.GetParameters(); + if (m.GetParameters().Length != proxyMethodParameters.Length) + return false; + for (int i = 0; i < mParameters.Length; i++) + { + // TODO: decide if "assignable" checking is desired (instead of just 1:1 type equality) + // TODO: test if this actually works + if (!mParameters[i].ParameterType.IsAssignableFrom(proxyMethodParameters[i].ParameterType)) + return false; + } + return true; + }).FirstOrDefault(); if (targetMethod == null) throw new InvalidOperationException($"The {interfaceType.FullName} interface defines method {proxyMethod.Name} which doesn't exist in the API."); -- cgit From 3135925982b01d8f316adc171c7c1235ea41c1d3 Mon Sep 17 00:00:00 2001 From: Shockah Date: Tue, 8 Feb 2022 21:54:53 +0100 Subject: allow generic methods and any assignable types in API proxies --- .../Framework/Reflection/InterfaceProxyBuilder.cs | 53 ++++++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 164cac0b..35faa852 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -65,15 +65,30 @@ namespace StardewModdingAPI.Framework.Reflection } } + bool AreTypesMatching(Type targetType, Type proxyType, MethodTypeMatchingPart part) + { + var typeA = part == MethodTypeMatchingPart.Parameter ? targetType : proxyType; + var typeB = part == MethodTypeMatchingPart.Parameter ? proxyType : targetType; + + if (typeA.IsGenericMethodParameter != typeB.IsGenericMethodParameter) + return false; + // TODO: decide if "assignable" checking is desired (instead of just 1:1 type equality) + return typeA.IsGenericMethodParameter ? typeA.GenericParameterPosition == typeB.GenericParameterPosition : typeA.IsAssignableFrom(typeB); + } + // proxy methods foreach (MethodInfo proxyMethod in interfaceType.GetMethods()) { var proxyMethodParameters = proxyMethod.GetParameters(); + var proxyMethodGenericArguments = proxyMethod.GetGenericArguments(); var targetMethod = allTargetMethods.Where(m => { if (m.Name != proxyMethod.Name) return false; - if (m.ReturnType != proxyMethod.ReturnType) + + if (m.GetGenericArguments().Length != proxyMethodGenericArguments.Length) + return false; + if (!AreTypesMatching(m.ReturnType, proxyMethod.ReturnType, MethodTypeMatchingPart.ReturnType)) return false; var mParameters = m.GetParameters(); @@ -81,9 +96,7 @@ namespace StardewModdingAPI.Framework.Reflection return false; for (int i = 0; i < mParameters.Length; i++) { - // TODO: decide if "assignable" checking is desired (instead of just 1:1 type equality) - // TODO: test if this actually works - if (!mParameters[i].ParameterType.IsAssignableFrom(proxyMethodParameters[i].ParameterType)) + if (!AreTypesMatching(mParameters[i].ParameterType, proxyMethodParameters[i].ParameterType, MethodTypeMatchingPart.Parameter)) return false; } return true; @@ -91,7 +104,7 @@ namespace StardewModdingAPI.Framework.Reflection if (targetMethod == null) throw new InvalidOperationException($"The {interfaceType.FullName} interface defines method {proxyMethod.Name} which doesn't exist in the API."); - this.ProxyMethod(proxyBuilder, targetMethod, targetField); + this.ProxyMethod(proxyBuilder, proxyMethod, targetMethod, targetField); } // save info @@ -115,16 +128,30 @@ namespace StardewModdingAPI.Framework.Reflection *********/ /// Define a method which proxies access to a method on the target. /// The proxy type being generated. + /// The proxy method. /// The target method. /// The proxy field containing the API instance. - private void ProxyMethod(TypeBuilder proxyBuilder, MethodInfo target, FieldBuilder instanceField) + private void ProxyMethod(TypeBuilder proxyBuilder, MethodInfo proxy, MethodInfo target, FieldBuilder instanceField) { - Type[] argTypes = target.GetParameters().Select(a => a.ParameterType).ToArray(); - - // create method MethodBuilder methodBuilder = proxyBuilder.DefineMethod(target.Name, MethodAttributes.Public | MethodAttributes.Final | MethodAttributes.Virtual); + + // set up generic arguments + Type[] proxyGenericArguments = proxy.GetGenericArguments(); + string[] genericArgNames = proxyGenericArguments.Select(a => a.Name).ToArray(); + GenericTypeParameterBuilder[] genericTypeParameterBuilders = proxyGenericArguments.Length == 0 ? null : methodBuilder.DefineGenericParameters(genericArgNames); + for (int i = 0; i < proxyGenericArguments.Length; i++) + genericTypeParameterBuilders[i].SetGenericParameterAttributes(proxyGenericArguments[i].GenericParameterAttributes); + + // set up return type + // TODO: keep if it's decided to use isAssignableFrom + methodBuilder.SetReturnType(proxy.ReturnType.IsGenericMethodParameter ? genericTypeParameterBuilders[proxy.ReturnType.GenericParameterPosition] : proxy.ReturnType); + + // set up parameters + Type[] argTypes = proxy.GetParameters() + .Select(a => a.ParameterType) + .Select(t => t.IsGenericMethodParameter ? genericTypeParameterBuilders[t.GenericParameterPosition] : t) + .ToArray(); methodBuilder.SetParameters(argTypes); - methodBuilder.SetReturnType(target.ReturnType); // create method body { @@ -143,5 +170,11 @@ namespace StardewModdingAPI.Framework.Reflection il.Emit(OpCodes.Ret); } } + + /// The part of a method that is being matched. + private enum MethodTypeMatchingPart + { + ReturnType, Parameter + } } } -- cgit From 5b5304403b79ce09bd06d1c4b52b67427069b772 Mon Sep 17 00:00:00 2001 From: Shockah Date: Tue, 8 Feb 2022 22:19:16 +0100 Subject: oops old code --- src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 35faa852..4c154679 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -143,7 +143,6 @@ namespace StardewModdingAPI.Framework.Reflection genericTypeParameterBuilders[i].SetGenericParameterAttributes(proxyGenericArguments[i].GenericParameterAttributes); // set up return type - // TODO: keep if it's decided to use isAssignableFrom methodBuilder.SetReturnType(proxy.ReturnType.IsGenericMethodParameter ? genericTypeParameterBuilders[proxy.ReturnType.GenericParameterPosition] : proxy.ReturnType); // set up parameters -- cgit From 5a92b0cd357776eebb88e001384f9ca1ccdb7d5c Mon Sep 17 00:00:00 2001 From: Shockah Date: Tue, 8 Feb 2022 22:36:34 +0100 Subject: uses `proxy.Name` instead of `target.Name` (which makes more sense in this context) --- src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 4c154679..5ae96dff 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -133,7 +133,7 @@ namespace StardewModdingAPI.Framework.Reflection /// The proxy field containing the API instance. private void ProxyMethod(TypeBuilder proxyBuilder, MethodInfo proxy, MethodInfo target, FieldBuilder instanceField) { - MethodBuilder methodBuilder = proxyBuilder.DefineMethod(target.Name, MethodAttributes.Public | MethodAttributes.Final | MethodAttributes.Virtual); + MethodBuilder methodBuilder = proxyBuilder.DefineMethod(proxy.Name, MethodAttributes.Public | MethodAttributes.Final | MethodAttributes.Virtual); // set up generic arguments Type[] proxyGenericArguments = proxy.GetGenericArguments(); -- cgit From 43ad219b75740ef71ad9bad496b00c076182619b Mon Sep 17 00:00:00 2001 From: Shockah Date: Wed, 9 Feb 2022 20:07:01 +0100 Subject: support proxying return values in API proxies --- .../Framework/Reflection/InterfaceProxyBuilder.cs | 183 ++++++++++++++++----- .../Framework/Reflection/InterfaceProxyFactory.cs | 30 +++- .../Framework/Reflection/InterfaceProxyGlue.cs | 18 ++ 3 files changed, 186 insertions(+), 45 deletions(-) create mode 100644 src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 5ae96dff..d8b066bd 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -1,13 +1,22 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Reflection.Emit; +using HarmonyLib; namespace StardewModdingAPI.Framework.Reflection { /// Generates a proxy class to access a mod API through an arbitrary interface. internal class InterfaceProxyBuilder { + /********* + ** Consts + *********/ + private static readonly string TargetFieldName = "__Target"; + private static readonly string GlueFieldName = "__Glue"; + private static readonly MethodInfo CreateInstanceForProxyTypeNameMethod = typeof(InterfaceProxyGlue).GetMethod(nameof(InterfaceProxyGlue.CreateInstanceForProxyTypeName), new Type[] { typeof(string), typeof(object) }); + /********* ** Fields *********/ @@ -22,11 +31,14 @@ namespace StardewModdingAPI.Framework.Reflection ** Public methods *********/ /// Construct an instance. + /// The that requested to build a proxy. /// The type name to generate. /// The CLR module in which to create proxy classes. /// The interface type to implement. /// The target type. - public InterfaceProxyBuilder(string name, ModuleBuilder moduleBuilder, Type interfaceType, Type targetType) + /// The unique ID of the mod consuming the API. + /// The unique ID of the mod providing the API. + public InterfaceProxyBuilder(InterfaceProxyFactory factory, string name, ModuleBuilder moduleBuilder, Type interfaceType, Type targetType, string sourceModID, string targetModID) { // validate if (name == null) @@ -38,12 +50,13 @@ namespace StardewModdingAPI.Framework.Reflection TypeBuilder proxyBuilder = moduleBuilder.DefineType(name, TypeAttributes.Public | TypeAttributes.Class); proxyBuilder.AddInterfaceImplementation(interfaceType); - // create field to store target instance - FieldBuilder targetField = proxyBuilder.DefineField("__Target", targetType, FieldAttributes.Private); + // create fields to store target instance and proxy factory + FieldBuilder targetField = proxyBuilder.DefineField(TargetFieldName, targetType, FieldAttributes.Private); + FieldBuilder glueField = proxyBuilder.DefineField(GlueFieldName, typeof(InterfaceProxyGlue), FieldAttributes.Private); - // create constructor which accepts target instance and sets field + // create constructor which accepts target instance + factory, and sets fields { - ConstructorBuilder constructor = proxyBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard | CallingConventions.HasThis, new[] { targetType }); + ConstructorBuilder constructor = proxyBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard | CallingConventions.HasThis, new[] { targetType, typeof(InterfaceProxyGlue) }); ILGenerator il = constructor.GetILGenerator(); il.Emit(OpCodes.Ldarg_0); // this @@ -52,6 +65,9 @@ namespace StardewModdingAPI.Framework.Reflection il.Emit(OpCodes.Ldarg_0); // this il.Emit(OpCodes.Ldarg_1); // load argument il.Emit(OpCodes.Stfld, targetField); // set field to loaded argument + il.Emit(OpCodes.Ldarg_0); // this + il.Emit(OpCodes.Ldarg_2); // load argument + il.Emit(OpCodes.Stfld, glueField); // set field to loaded argument il.Emit(OpCodes.Ret); } @@ -65,15 +81,20 @@ namespace StardewModdingAPI.Framework.Reflection } } - bool AreTypesMatching(Type targetType, Type proxyType, MethodTypeMatchingPart part) + MatchingTypesResult AreTypesMatching(Type targetType, Type proxyType, MethodTypeMatchingPart part) { var typeA = part == MethodTypeMatchingPart.Parameter ? targetType : proxyType; var typeB = part == MethodTypeMatchingPart.Parameter ? proxyType : targetType; if (typeA.IsGenericMethodParameter != typeB.IsGenericMethodParameter) - return false; + return MatchingTypesResult.False; // TODO: decide if "assignable" checking is desired (instead of just 1:1 type equality) - return typeA.IsGenericMethodParameter ? typeA.GenericParameterPosition == typeB.GenericParameterPosition : typeA.IsAssignableFrom(typeB); + if (typeA.IsGenericMethodParameter ? typeA.GenericParameterPosition == typeB.GenericParameterPosition : typeA.IsAssignableFrom(typeB)) + return MatchingTypesResult.True; + + if (!proxyType.IsGenericMethodParameter && proxyType.IsInterface && proxyType.Assembly == interfaceType.Assembly) + return MatchingTypesResult.IfProxied; + return MatchingTypesResult.False; } // proxy methods @@ -81,30 +102,61 @@ namespace StardewModdingAPI.Framework.Reflection { var proxyMethodParameters = proxyMethod.GetParameters(); var proxyMethodGenericArguments = proxyMethod.GetGenericArguments(); - var targetMethod = allTargetMethods.Where(m => + + foreach (MethodInfo targetMethod in allTargetMethods) { - if (m.Name != proxyMethod.Name) - return false; + // checking if `targetMethod` matches `proxyMethod` - if (m.GetGenericArguments().Length != proxyMethodGenericArguments.Length) - return false; - if (!AreTypesMatching(m.ReturnType, proxyMethod.ReturnType, MethodTypeMatchingPart.ReturnType)) - return false; + if (targetMethod.Name != proxyMethod.Name) + continue; + if (targetMethod.GetGenericArguments().Length != proxyMethodGenericArguments.Length) + continue; + var positionsToProxy = new HashSet(); // null = return type; anything else = parameter position + + switch (AreTypesMatching(targetMethod.ReturnType, proxyMethod.ReturnType, MethodTypeMatchingPart.ReturnType)) + { + case MatchingTypesResult.False: + continue; + case MatchingTypesResult.True: + break; + case MatchingTypesResult.IfProxied: + positionsToProxy.Add(null); + break; + } - var mParameters = m.GetParameters(); - if (m.GetParameters().Length != proxyMethodParameters.Length) - return false; + var mParameters = targetMethod.GetParameters(); + if (mParameters.Length != proxyMethodParameters.Length) + continue; for (int i = 0; i < mParameters.Length; i++) { - if (!AreTypesMatching(mParameters[i].ParameterType, proxyMethodParameters[i].ParameterType, MethodTypeMatchingPart.Parameter)) - return false; + switch (AreTypesMatching(mParameters[i].ParameterType, proxyMethodParameters[i].ParameterType, MethodTypeMatchingPart.Parameter)) + { + case MatchingTypesResult.False: + goto targetMethodLoopContinue; + case MatchingTypesResult.True: + break; + case MatchingTypesResult.IfProxied: + if (proxyMethodParameters[i].IsOut) + { + positionsToProxy.Add(i); + break; + } + else + { + goto targetMethodLoopContinue; + } + } } - return true; - }).FirstOrDefault(); - if (targetMethod == null) - throw new InvalidOperationException($"The {interfaceType.FullName} interface defines method {proxyMethod.Name} which doesn't exist in the API."); - this.ProxyMethod(proxyBuilder, proxyMethod, targetMethod, targetField); + // method matched; proxying + + this.ProxyMethod(factory, proxyBuilder, proxyMethod, targetMethod, targetField, glueField, positionsToProxy, sourceModID, targetModID); + goto proxyMethodLoopContinue; + targetMethodLoopContinue:; + } + + throw new InvalidOperationException($"The {interfaceType.FullName} interface defines method {proxyMethod.Name} which doesn't exist in the API."); + proxyMethodLoopContinue:; } // save info @@ -114,12 +166,13 @@ namespace StardewModdingAPI.Framework.Reflection /// Create an instance of the proxy for a target instance. /// The target instance. - public object CreateInstance(object targetInstance) + /// The that requested to build a proxy. + public object CreateInstance(object targetInstance, InterfaceProxyFactory factory) { - ConstructorInfo constructor = this.ProxyType.GetConstructor(new[] { this.TargetType }); + ConstructorInfo constructor = this.ProxyType.GetConstructor(new[] { this.TargetType, typeof(InterfaceProxyGlue) }); if (constructor == null) throw new InvalidOperationException($"Couldn't find the constructor for generated proxy type '{this.ProxyType.Name}'."); // should never happen - return constructor.Invoke(new[] { targetInstance }); + return constructor.Invoke(new[] { targetInstance, new InterfaceProxyGlue(factory) }); } @@ -127,11 +180,16 @@ namespace StardewModdingAPI.Framework.Reflection ** Private methods *********/ /// Define a method which proxies access to a method on the target. + /// The that requested to build a proxy. /// The proxy type being generated. /// The proxy method. /// The target method. /// The proxy field containing the API instance. - private void ProxyMethod(TypeBuilder proxyBuilder, MethodInfo proxy, MethodInfo target, FieldBuilder instanceField) + /// The proxy field containing an . + /// Parameter type positions (or null for the return type) for which types should also be proxied. + /// The unique ID of the mod consuming the API. + /// The unique ID of the mod providing the API. + private void ProxyMethod(InterfaceProxyFactory factory, TypeBuilder proxyBuilder, MethodInfo proxy, MethodInfo target, FieldBuilder instanceField, FieldBuilder glueField, ISet positionsToProxy, string sourceModID, string targetModID) { MethodBuilder methodBuilder = proxyBuilder.DefineMethod(proxy.Name, MethodAttributes.Public | MethodAttributes.Final | MethodAttributes.Virtual); @@ -143,7 +201,8 @@ namespace StardewModdingAPI.Framework.Reflection genericTypeParameterBuilders[i].SetGenericParameterAttributes(proxyGenericArguments[i].GenericParameterAttributes); // set up return type - methodBuilder.SetReturnType(proxy.ReturnType.IsGenericMethodParameter ? genericTypeParameterBuilders[proxy.ReturnType.GenericParameterPosition] : proxy.ReturnType); + Type returnType = proxy.ReturnType.IsGenericMethodParameter ? genericTypeParameterBuilders[proxy.ReturnType.GenericParameterPosition] : proxy.ReturnType; + methodBuilder.SetReturnType(returnType); // set up parameters Type[] argTypes = proxy.GetParameters() @@ -152,18 +211,62 @@ namespace StardewModdingAPI.Framework.Reflection .ToArray(); methodBuilder.SetParameters(argTypes); + // proxy additional types + string returnValueProxyTypeName = null; + string[] parameterProxyTypeNames = new string[argTypes.Length]; + if (positionsToProxy.Count > 0) + { + var targetParameters = target.GetParameters(); + foreach (int? position in positionsToProxy) + { + // we don't check for generics here, because earlier code does and generic positions won't end up here + if (position == null) // it's the return type + { + var builder = factory.ObtainBuilder(target.ReturnType, proxy.ReturnType, sourceModID, targetModID); + returnType = proxy.ReturnType; + returnValueProxyTypeName = builder.ProxyType.FullName; + } + else // it's one of the parameters + { + var builder = factory.ObtainBuilder(targetParameters[position.Value].ParameterType, argTypes[position.Value], sourceModID, targetModID); + argTypes[position.Value] = proxy.ReturnType; + parameterProxyTypeNames[position.Value] = builder.ProxyType.FullName; + } + } + + methodBuilder.SetReturnType(returnType); + methodBuilder.SetParameters(argTypes); + } + // create method body { ILGenerator il = methodBuilder.GetILGenerator(); - // load target instance - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Ldfld, instanceField); + void EmitCallInstance() + { + // load target instance + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, instanceField); + + // invoke target method on instance + for (int i = 0; i < argTypes.Length; i++) + il.Emit(OpCodes.Ldarg, i + 1); + il.Emit(OpCodes.Callvirt, target); + } - // invoke target method on instance - for (int i = 0; i < argTypes.Length; i++) - il.Emit(OpCodes.Ldarg, i + 1); - il.Emit(OpCodes.Call, target); + if (returnValueProxyTypeName == null) + { + EmitCallInstance(); + } + else + { + // this.Glue.CreateInstanceForProxyTypeName(proxyTypeName, this.Instance.Call(args)) + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, glueField); + il.Emit(OpCodes.Ldstr, returnValueProxyTypeName); + EmitCallInstance(); + il.Emit(OpCodes.Call, CreateInstanceForProxyTypeNameMethod); + } // return result il.Emit(OpCodes.Ret); @@ -175,5 +278,11 @@ namespace StardewModdingAPI.Framework.Reflection { ReturnType, Parameter } + + /// The result of matching a target and a proxy type. + private enum MatchingTypesResult + { + False, IfProxied, True + } } } diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs index 5acba569..8ce187bf 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs @@ -35,26 +35,40 @@ namespace StardewModdingAPI.Framework.Reflection /// The unique ID of the mod providing the API. public TInterface CreateProxy(object instance, string sourceModID, string targetModID) where TInterface : class + { + // validate + if (instance == null) + throw new InvalidOperationException("Can't proxy access to a null API."); + + // create instance + InterfaceProxyBuilder builder = this.ObtainBuilder(instance.GetType(), typeof(TInterface), sourceModID, targetModID); + return (TInterface)builder.CreateInstance(instance, this); + } + + internal InterfaceProxyBuilder ObtainBuilder(Type targetType, Type interfaceType, string sourceModID, string targetModID) { lock (this.Builders) { // validate - if (instance == null) - throw new InvalidOperationException("Can't proxy access to a null API."); - if (!typeof(TInterface).IsInterface) + if (!interfaceType.IsInterface) throw new InvalidOperationException("The proxy type must be an interface, not a class."); // get proxy type - Type targetType = instance.GetType(); - string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{typeof(TInterface).FullName}>_To<{targetModID}_{targetType.FullName}>"; + string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{interfaceType.FullName}>_To<{targetModID}_{targetType.FullName}>"; if (!this.Builders.TryGetValue(proxyTypeName, out InterfaceProxyBuilder builder)) { - builder = new InterfaceProxyBuilder(proxyTypeName, this.ModuleBuilder, typeof(TInterface), targetType); + builder = new InterfaceProxyBuilder(this, proxyTypeName, this.ModuleBuilder, interfaceType, targetType, sourceModID, targetModID); this.Builders[proxyTypeName] = builder; } + return builder; + } + } - // create instance - return (TInterface)builder.CreateInstance(instance); + internal InterfaceProxyBuilder GetBuilderByProxyTypeName(string proxyTypeName) + { + lock (this.Builders) + { + return this.Builders.TryGetValue(proxyTypeName, out InterfaceProxyBuilder builder) ? builder : null; } } } diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs new file mode 100644 index 00000000..4e027252 --- /dev/null +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Framework.Reflection +{ + public sealed class InterfaceProxyGlue + { + private readonly InterfaceProxyFactory Factory; + + internal InterfaceProxyGlue(InterfaceProxyFactory factory) + { + this.Factory = factory; + } + + public object CreateInstanceForProxyTypeName(string proxyTypeName, object toProxy) + { + var builder = this.Factory.GetBuilderByProxyTypeName(proxyTypeName); + return builder.CreateInstance(toProxy, this.Factory); + } + } +} -- cgit From ee78ab3c3710639ec7eecb3d2edc7f26ff998407 Mon Sep 17 00:00:00 2001 From: Shockah Date: Wed, 9 Feb 2022 20:38:14 +0100 Subject: fix stack overflow for proxied types referencing each other --- .../Framework/Reflection/InterfaceProxyBuilder.cs | 41 ++++++++++++---------- .../Framework/Reflection/InterfaceProxyFactory.cs | 11 +++++- 2 files changed, 33 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index d8b066bd..99aea75c 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -23,40 +23,46 @@ namespace StardewModdingAPI.Framework.Reflection /// The target class type. private readonly Type TargetType; + /// The full name of the generated proxy type. + private readonly string ProxyTypeName; + /// The generated proxy type. - private readonly Type ProxyType; + private Type ProxyType; /********* ** Public methods *********/ /// Construct an instance. + /// The target type. + /// The type name to generate. + public InterfaceProxyBuilder(Type targetType, string proxyTypeName) + { + // validate + this.TargetType = targetType ?? throw new ArgumentNullException(nameof(targetType)); + this.ProxyTypeName = proxyTypeName ?? throw new ArgumentNullException(nameof(proxyTypeName)); + } + + + /// Creates and sets up the proxy type. /// The that requested to build a proxy. - /// The type name to generate. /// The CLR module in which to create proxy classes. /// The interface type to implement. - /// The target type. /// The unique ID of the mod consuming the API. /// The unique ID of the mod providing the API. - public InterfaceProxyBuilder(InterfaceProxyFactory factory, string name, ModuleBuilder moduleBuilder, Type interfaceType, Type targetType, string sourceModID, string targetModID) + public void SetupProxyType(InterfaceProxyFactory factory, ModuleBuilder moduleBuilder, Type interfaceType, string sourceModID, string targetModID) { - // validate - if (name == null) - throw new ArgumentNullException(nameof(name)); - if (targetType == null) - throw new ArgumentNullException(nameof(targetType)); - // define proxy type - TypeBuilder proxyBuilder = moduleBuilder.DefineType(name, TypeAttributes.Public | TypeAttributes.Class); + TypeBuilder proxyBuilder = moduleBuilder.DefineType(this.ProxyTypeName, TypeAttributes.Public | TypeAttributes.Class); proxyBuilder.AddInterfaceImplementation(interfaceType); // create fields to store target instance and proxy factory - FieldBuilder targetField = proxyBuilder.DefineField(TargetFieldName, targetType, FieldAttributes.Private); + FieldBuilder targetField = proxyBuilder.DefineField(TargetFieldName, this.TargetType, FieldAttributes.Private); FieldBuilder glueField = proxyBuilder.DefineField(GlueFieldName, typeof(InterfaceProxyGlue), FieldAttributes.Private); // create constructor which accepts target instance + factory, and sets fields { - ConstructorBuilder constructor = proxyBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard | CallingConventions.HasThis, new[] { targetType, typeof(InterfaceProxyGlue) }); + ConstructorBuilder constructor = proxyBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard | CallingConventions.HasThis, new[] { this.TargetType, typeof(InterfaceProxyGlue) }); ILGenerator il = constructor.GetILGenerator(); il.Emit(OpCodes.Ldarg_0); // this @@ -71,8 +77,8 @@ namespace StardewModdingAPI.Framework.Reflection il.Emit(OpCodes.Ret); } - var allTargetMethods = targetType.GetMethods().ToList(); - foreach (Type targetInterface in targetType.GetInterfaces()) + var allTargetMethods = this.TargetType.GetMethods().ToList(); + foreach (Type targetInterface in this.TargetType.GetInterfaces()) { foreach (MethodInfo targetMethod in targetInterface.GetMethods()) { @@ -160,7 +166,6 @@ namespace StardewModdingAPI.Framework.Reflection } // save info - this.TargetType = targetType; this.ProxyType = proxyBuilder.CreateType(); } @@ -224,13 +229,13 @@ namespace StardewModdingAPI.Framework.Reflection { var builder = factory.ObtainBuilder(target.ReturnType, proxy.ReturnType, sourceModID, targetModID); returnType = proxy.ReturnType; - returnValueProxyTypeName = builder.ProxyType.FullName; + returnValueProxyTypeName = builder.ProxyTypeName; } else // it's one of the parameters { var builder = factory.ObtainBuilder(targetParameters[position.Value].ParameterType, argTypes[position.Value], sourceModID, targetModID); argTypes[position.Value] = proxy.ReturnType; - parameterProxyTypeNames[position.Value] = builder.ProxyType.FullName; + parameterProxyTypeNames[position.Value] = builder.ProxyTypeName; } } diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs index 8ce187bf..72b4254c 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs @@ -57,8 +57,17 @@ namespace StardewModdingAPI.Framework.Reflection string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{interfaceType.FullName}>_To<{targetModID}_{targetType.FullName}>"; if (!this.Builders.TryGetValue(proxyTypeName, out InterfaceProxyBuilder builder)) { - builder = new InterfaceProxyBuilder(this, proxyTypeName, this.ModuleBuilder, interfaceType, targetType, sourceModID, targetModID); + builder = new InterfaceProxyBuilder(targetType, proxyTypeName); this.Builders[proxyTypeName] = builder; + try + { + builder.SetupProxyType(this, this.ModuleBuilder, interfaceType, sourceModID, targetModID); + } + catch + { + this.Builders.Remove(proxyTypeName); + throw; + } } return builder; } -- cgit From 688fccc0246be6756a07933696a235dca8f1a395 Mon Sep 17 00:00:00 2001 From: Shockah Date: Wed, 9 Feb 2022 20:40:54 +0100 Subject: add missing documentation --- src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs index 4e027252..8d0d74a7 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs @@ -1,5 +1,6 @@ namespace StardewModdingAPI.Framework.Reflection { + /// Provides an interface for proxied types to create other proxied types. public sealed class InterfaceProxyGlue { private readonly InterfaceProxyFactory Factory; @@ -9,6 +10,9 @@ namespace StardewModdingAPI.Framework.Reflection this.Factory = factory; } + /// Creates a new proxied instance by its type name. + /// The full name of the proxy type. + /// The target instance to proxy. public object CreateInstanceForProxyTypeName(string proxyTypeName, object toProxy) { var builder = this.Factory.GetBuilderByProxyTypeName(proxyTypeName); -- cgit From 354527bb810d06f69f2a8c45d1a23f294d228caf Mon Sep 17 00:00:00 2001 From: Shockah Date: Wed, 9 Feb 2022 21:02:41 +0100 Subject: stop proxying nulls --- src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 99aea75c..1b6bf1d6 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -265,12 +265,24 @@ namespace StardewModdingAPI.Framework.Reflection } else { - // this.Glue.CreateInstanceForProxyTypeName(proxyTypeName, this.Instance.Call(args)) + var resultLocal = il.DeclareLocal(typeof(object)); // we store both unmodified and modified in here + EmitCallInstance(); + il.Emit(OpCodes.Stloc, resultLocal); + + // if (unmodifiedResultLocal == null) jump + var isNullLabel = il.DefineLabel(); + il.Emit(OpCodes.Ldloc, resultLocal); + il.Emit(OpCodes.Brfalse, isNullLabel); + il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ldfld, glueField); il.Emit(OpCodes.Ldstr, returnValueProxyTypeName); - EmitCallInstance(); + il.Emit(OpCodes.Ldloc, resultLocal); il.Emit(OpCodes.Call, CreateInstanceForProxyTypeNameMethod); + il.Emit(OpCodes.Stloc, resultLocal); + + il.MarkLabel(isNullLabel); + il.Emit(OpCodes.Ldloc, resultLocal); } // return result -- cgit From d9599a3a0af500438fa71addb1a25d4608aefda5 Mon Sep 17 00:00:00 2001 From: Shockah Date: Wed, 9 Feb 2022 21:10:20 +0100 Subject: simplifies proxy method IL a bit --- .../Framework/Reflection/InterfaceProxyBuilder.cs | 32 +++++++--------------- 1 file changed, 10 insertions(+), 22 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 1b6bf1d6..5e0dd838 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -247,28 +247,16 @@ namespace StardewModdingAPI.Framework.Reflection { ILGenerator il = methodBuilder.GetILGenerator(); - void EmitCallInstance() + var resultLocal = il.DeclareLocal(typeof(object)); // we store both unmodified and modified in here, hence `object` + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, instanceField); + for (int i = 0; i < argTypes.Length; i++) + il.Emit(OpCodes.Ldarg, i + 1); + il.Emit(OpCodes.Callvirt, target); + il.Emit(OpCodes.Stloc, resultLocal); + + if (returnValueProxyTypeName != null) { - // load target instance - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Ldfld, instanceField); - - // invoke target method on instance - for (int i = 0; i < argTypes.Length; i++) - il.Emit(OpCodes.Ldarg, i + 1); - il.Emit(OpCodes.Callvirt, target); - } - - if (returnValueProxyTypeName == null) - { - EmitCallInstance(); - } - else - { - var resultLocal = il.DeclareLocal(typeof(object)); // we store both unmodified and modified in here - EmitCallInstance(); - il.Emit(OpCodes.Stloc, resultLocal); - // if (unmodifiedResultLocal == null) jump var isNullLabel = il.DefineLabel(); il.Emit(OpCodes.Ldloc, resultLocal); @@ -282,10 +270,10 @@ namespace StardewModdingAPI.Framework.Reflection il.Emit(OpCodes.Stloc, resultLocal); il.MarkLabel(isNullLabel); - il.Emit(OpCodes.Ldloc, resultLocal); } // return result + il.Emit(OpCodes.Ldloc, resultLocal); il.Emit(OpCodes.Ret); } } -- cgit From f920ed59d6234df9d33b0d72ea0398a3fa8e9b8b Mon Sep 17 00:00:00 2001 From: Shockah Date: Wed, 9 Feb 2022 23:26:26 +0100 Subject: add WIP proxying of methods with `out` parameters --- .../Framework/Reflection/InterfaceProxyBuilder.cs | 66 ++++++++++++++++++---- 1 file changed, 56 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 5e0dd838..b1b3c451 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -98,7 +98,7 @@ namespace StardewModdingAPI.Framework.Reflection if (typeA.IsGenericMethodParameter ? typeA.GenericParameterPosition == typeB.GenericParameterPosition : typeA.IsAssignableFrom(typeB)) return MatchingTypesResult.True; - if (!proxyType.IsGenericMethodParameter && proxyType.IsInterface && proxyType.Assembly == interfaceType.Assembly) + if (!proxyType.IsGenericMethodParameter && proxyType.GetNonRefType().IsInterface && proxyType.Assembly == interfaceType.Assembly) return MatchingTypesResult.IfProxied; return MatchingTypesResult.False; } @@ -233,8 +233,15 @@ namespace StardewModdingAPI.Framework.Reflection } else // it's one of the parameters { - var builder = factory.ObtainBuilder(targetParameters[position.Value].ParameterType, argTypes[position.Value], sourceModID, targetModID); - argTypes[position.Value] = proxy.ReturnType; + bool isByRef = argTypes[position.Value].IsByRef; + var targetType = targetParameters[position.Value].ParameterType.GetNonRefType(); + var argType = argTypes[position.Value].GetNonRefType(); + + var builder = factory.ObtainBuilder(targetType, argType, sourceModID, targetModID); + if (isByRef) + argType = argType.MakeByRefType(); + + argTypes[position.Value] = argType; parameterProxyTypeNames[position.Value] = builder.ProxyTypeName; } } @@ -246,32 +253,63 @@ namespace StardewModdingAPI.Framework.Reflection // create method body { ILGenerator il = methodBuilder.GetILGenerator(); + LocalBuilder[] outLocals = new LocalBuilder[argTypes.Length]; + // calling the proxied method var resultLocal = il.DeclareLocal(typeof(object)); // we store both unmodified and modified in here, hence `object` il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ldfld, instanceField); for (int i = 0; i < argTypes.Length; i++) - il.Emit(OpCodes.Ldarg, i + 1); + { + if (parameterProxyTypeNames[i] == null) + { + il.Emit(OpCodes.Ldarg, i + 1); + } + else + { + // previous code already checks if the parameters are specifically `out` + outLocals[i] = il.DeclareLocal(typeof(object)); // we store both unmodified and modified in here, hence `object` + il.Emit(OpCodes.Ldloca_S, outLocals[i]); + } + } il.Emit(OpCodes.Callvirt, target); il.Emit(OpCodes.Stloc, resultLocal); - if (returnValueProxyTypeName != null) + void ProxyNonNullIfNeeded(LocalBuilder local, string proxyTypeName) { - // if (unmodifiedResultLocal == null) jump + if (proxyTypeName == null) + return; + var isNullLabel = il.DefineLabel(); - il.Emit(OpCodes.Ldloc, resultLocal); + il.Emit(OpCodes.Ldloc, local); il.Emit(OpCodes.Brfalse, isNullLabel); il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ldfld, glueField); - il.Emit(OpCodes.Ldstr, returnValueProxyTypeName); - il.Emit(OpCodes.Ldloc, resultLocal); + il.Emit(OpCodes.Ldstr, proxyTypeName); + il.Emit(OpCodes.Ldloc, local); il.Emit(OpCodes.Call, CreateInstanceForProxyTypeNameMethod); - il.Emit(OpCodes.Stloc, resultLocal); + il.Emit(OpCodes.Stloc, local); il.MarkLabel(isNullLabel); } + // proxying `out` parameters + for (int i = 0; i < argTypes.Length; i++) + { + if (parameterProxyTypeNames[i] == null) + continue; + // previous code already checks if the parameters are specifically `out` + + ProxyNonNullIfNeeded(outLocals[i], parameterProxyTypeNames[i]); + il.Emit(OpCodes.Ldarg, i + 1); + il.Emit(OpCodes.Ldloc, outLocals[i]); + il.Emit(OpCodes.Stind_Ref); + } + + // proxying return value + ProxyNonNullIfNeeded(resultLocal, returnValueProxyTypeName); + // return result il.Emit(OpCodes.Ldloc, resultLocal); il.Emit(OpCodes.Ret); @@ -290,4 +328,12 @@ namespace StardewModdingAPI.Framework.Reflection False, IfProxied, True } } + + internal static class TypeExtensions + { + internal static Type GetNonRefType(this Type type) + { + return type.IsByRef ? type.GetElementType() : type; + } + } } -- cgit From 51b7b9fe06d91d59461f9b9d6d3c1b1c736dc34d Mon Sep 17 00:00:00 2001 From: Ameisen <14104310+ameisen@users.noreply.github.com> Date: Wed, 9 Feb 2022 18:00:15 -0600 Subject: Cleanup and performance/allocation improvement for AssetDataForImage.PatchImage --- src/SMAPI/Framework/Content/AssetDataForImage.cs | 26 ++++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index 529fb93a..c75514bc 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -41,39 +41,40 @@ namespace StardewModdingAPI.Framework.Content targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height)); // validate - if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height) + if (!source.Bounds.Contains(sourceArea.Value)) throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture."); - if (targetArea.Value.X < 0 || targetArea.Value.Y < 0 || targetArea.Value.Right > target.Width || targetArea.Value.Bottom > target.Height) + if (!target.Bounds.Contains(targetArea.Value)) throw new ArgumentOutOfRangeException(nameof(targetArea), "The target area is outside the bounds of the target texture."); - if (sourceArea.Value.Width != targetArea.Value.Width || sourceArea.Value.Height != targetArea.Value.Height) + if (sourceArea.Value.Size != targetArea.Value.Size) throw new InvalidOperationException("The source and target areas must be the same size."); // get source data int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height; - Color[] sourceData = new Color[pixelCount]; + Color[] sourceData = GC.AllocateUninitializedArray(pixelCount); source.GetData(0, sourceArea, sourceData, 0, pixelCount); // merge data in overlay mode if (patchMode == PatchMode.Overlay) { // get target data - Color[] targetData = new Color[pixelCount]; + Color[] targetData = GC.AllocateUninitializedArray(pixelCount); target.GetData(0, targetArea, targetData, 0, pixelCount); // merge pixels - Color[] newData = new Color[targetArea.Value.Width * targetArea.Value.Height]; - target.GetData(0, targetArea, newData, 0, newData.Length); for (int i = 0; i < sourceData.Length; i++) { Color above = sourceData[i]; Color below = targetData[i]; // shortcut transparency - if (above.A < AssetDataForImage.MinOpacity) + if (above.A < MinOpacity) + { + sourceData[i] = below; continue; - if (below.A < AssetDataForImage.MinOpacity) + } + if (below.A < MinOpacity) { - newData[i] = above; + sourceData[i] = above; continue; } @@ -84,14 +85,13 @@ namespace StardewModdingAPI.Framework.Content // Note: don't use named arguments here since they're different between // Linux/macOS and Windows. float alphaBelow = 1 - (above.A / 255f); - newData[i] = new Color( + sourceData[i] = new Color( (int)(above.R + (below.R * alphaBelow)), // r (int)(above.G + (below.G * alphaBelow)), // g (int)(above.B + (below.B * alphaBelow)), // b Math.Max(above.A, below.A) // a ); } - sourceData = newData; } // patch target texture @@ -105,7 +105,7 @@ namespace StardewModdingAPI.Framework.Content return false; Texture2D original = this.Data; - Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)); + Texture2D texture = new(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)); this.ReplaceWith(texture); this.PatchImage(original); return true; -- cgit From 55723f91d270fa2623c25c0ca1decfa7e566b636 Mon Sep 17 00:00:00 2001 From: Shockah Date: Thu, 10 Feb 2022 10:08:14 +0100 Subject: implement `out` parameter proxying --- .../Framework/Reflection/InterfaceProxyBuilder.cs | 46 ++++++++++++++-------- 1 file changed, 29 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index b1b3c451..3c6d7c61 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Reflection.Emit; -using HarmonyLib; namespace StardewModdingAPI.Framework.Reflection { @@ -210,18 +209,17 @@ namespace StardewModdingAPI.Framework.Reflection methodBuilder.SetReturnType(returnType); // set up parameters + var targetParameters = target.GetParameters(); Type[] argTypes = proxy.GetParameters() .Select(a => a.ParameterType) .Select(t => t.IsGenericMethodParameter ? genericTypeParameterBuilders[t.GenericParameterPosition] : t) .ToArray(); - methodBuilder.SetParameters(argTypes); // proxy additional types string returnValueProxyTypeName = null; string[] parameterProxyTypeNames = new string[argTypes.Length]; if (positionsToProxy.Count > 0) { - var targetParameters = target.GetParameters(); foreach (int? position in positionsToProxy) { // we don't check for generics here, because earlier code does and generic positions won't end up here @@ -247,16 +245,21 @@ namespace StardewModdingAPI.Framework.Reflection } methodBuilder.SetReturnType(returnType); - methodBuilder.SetParameters(argTypes); } + methodBuilder.SetParameters(argTypes); + for (int i = 0; i < argTypes.Length; i++) + methodBuilder.DefineParameter(i, targetParameters[i].Attributes, targetParameters[i].Name); + // create method body { ILGenerator il = methodBuilder.GetILGenerator(); - LocalBuilder[] outLocals = new LocalBuilder[argTypes.Length]; + LocalBuilder[] outInputLocals = new LocalBuilder[argTypes.Length]; + LocalBuilder[] outOutputLocals = new LocalBuilder[argTypes.Length]; // calling the proxied method - var resultLocal = il.DeclareLocal(typeof(object)); // we store both unmodified and modified in here, hence `object` + LocalBuilder resultInputLocal = target.ReturnType == typeof(void) ? null : il.DeclareLocal(target.ReturnType); + LocalBuilder resultOutputLocal = returnType == typeof(void) ? null : il.DeclareLocal(returnType); il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ldfld, instanceField); for (int i = 0; i < argTypes.Length; i++) @@ -268,28 +271,35 @@ namespace StardewModdingAPI.Framework.Reflection else { // previous code already checks if the parameters are specifically `out` - outLocals[i] = il.DeclareLocal(typeof(object)); // we store both unmodified and modified in here, hence `object` - il.Emit(OpCodes.Ldloca_S, outLocals[i]); + outInputLocals[i] = il.DeclareLocal(targetParameters[i].ParameterType.GetNonRefType()); + outOutputLocals[i] = il.DeclareLocal(argTypes[i].GetNonRefType()); + il.Emit(OpCodes.Ldloca, outInputLocals[i]); } } il.Emit(OpCodes.Callvirt, target); - il.Emit(OpCodes.Stloc, resultLocal); + if (target.ReturnType != typeof(void)) + il.Emit(OpCodes.Stloc, resultInputLocal); - void ProxyNonNullIfNeeded(LocalBuilder local, string proxyTypeName) + void ProxyIfNeededAndStore(LocalBuilder inputLocal, LocalBuilder outputLocal, string proxyTypeName) { if (proxyTypeName == null) + { + il.Emit(OpCodes.Ldloc, inputLocal); + il.Emit(OpCodes.Stloc, outputLocal); return; + } var isNullLabel = il.DefineLabel(); - il.Emit(OpCodes.Ldloc, local); + il.Emit(OpCodes.Ldloc, inputLocal); il.Emit(OpCodes.Brfalse, isNullLabel); il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ldfld, glueField); il.Emit(OpCodes.Ldstr, proxyTypeName); - il.Emit(OpCodes.Ldloc, local); + il.Emit(OpCodes.Ldloc, inputLocal); il.Emit(OpCodes.Call, CreateInstanceForProxyTypeNameMethod); - il.Emit(OpCodes.Stloc, local); + il.Emit(OpCodes.Castclass, outputLocal.LocalType); + il.Emit(OpCodes.Stloc, outputLocal); il.MarkLabel(isNullLabel); } @@ -301,17 +311,19 @@ namespace StardewModdingAPI.Framework.Reflection continue; // previous code already checks if the parameters are specifically `out` - ProxyNonNullIfNeeded(outLocals[i], parameterProxyTypeNames[i]); + ProxyIfNeededAndStore(outInputLocals[i], outOutputLocals[i], parameterProxyTypeNames[i]); il.Emit(OpCodes.Ldarg, i + 1); - il.Emit(OpCodes.Ldloc, outLocals[i]); + il.Emit(OpCodes.Ldloc, outOutputLocals[i]); il.Emit(OpCodes.Stind_Ref); } // proxying return value - ProxyNonNullIfNeeded(resultLocal, returnValueProxyTypeName); + if (target.ReturnType != typeof(void)) + ProxyIfNeededAndStore(resultInputLocal, resultOutputLocal, returnValueProxyTypeName); // return result - il.Emit(OpCodes.Ldloc, resultLocal); + if (target.ReturnType != typeof(void)) + il.Emit(OpCodes.Ldloc, resultOutputLocal); il.Emit(OpCodes.Ret); } } -- cgit From 955790842518d68ca5df21efe1beccc6069a35fe Mon Sep 17 00:00:00 2001 From: Shockah Date: Thu, 10 Feb 2022 10:18:31 +0100 Subject: fix code style warning --- src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 3c6d7c61..9573c791 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -66,7 +66,7 @@ namespace StardewModdingAPI.Framework.Reflection il.Emit(OpCodes.Ldarg_0); // this // ReSharper disable once AssignNullToNotNullAttribute -- never null - il.Emit(OpCodes.Call, typeof(object).GetConstructor(new Type[0])); // call base constructor + il.Emit(OpCodes.Call, typeof(object).GetConstructor(Array.Empty())); // call base constructor il.Emit(OpCodes.Ldarg_0); // this il.Emit(OpCodes.Ldarg_1); // load argument il.Emit(OpCodes.Stfld, targetField); // set field to loaded argument -- cgit From 61415e41eb8f5f61e8b241255162257191c0a766 Mon Sep 17 00:00:00 2001 From: Shockah Date: Thu, 10 Feb 2022 11:21:41 +0100 Subject: use Call/Callvirt depending on target --- src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 9573c791..49cc6bca 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -276,7 +276,7 @@ namespace StardewModdingAPI.Framework.Reflection il.Emit(OpCodes.Ldloca, outInputLocals[i]); } } - il.Emit(OpCodes.Callvirt, target); + il.Emit(target.IsVirtual ? OpCodes.Callvirt : OpCodes.Call, target); if (target.ReturnType != typeof(void)) il.Emit(OpCodes.Stloc, resultInputLocal); -- cgit From 07259452170a253c44d5c2be68fc2342a88d2504 Mon Sep 17 00:00:00 2001 From: Shockah Date: Thu, 10 Feb 2022 11:43:35 +0100 Subject: add proxy instance caching --- .../Framework/Reflection/InterfaceProxyBuilder.cs | 18 +++++++++++++----- .../Framework/Reflection/InterfaceProxyFactory.cs | 2 +- src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs | 6 +++--- 3 files changed, 17 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 49cc6bca..63a594fa 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Reflection.Emit; +using System.Runtime.CompilerServices; namespace StardewModdingAPI.Framework.Reflection { @@ -14,7 +15,7 @@ namespace StardewModdingAPI.Framework.Reflection *********/ private static readonly string TargetFieldName = "__Target"; private static readonly string GlueFieldName = "__Glue"; - private static readonly MethodInfo CreateInstanceForProxyTypeNameMethod = typeof(InterfaceProxyGlue).GetMethod(nameof(InterfaceProxyGlue.CreateInstanceForProxyTypeName), new Type[] { typeof(string), typeof(object) }); + private static readonly MethodInfo ObtainInstanceForProxyTypeNameMethod = typeof(InterfaceProxyGlue).GetMethod(nameof(InterfaceProxyGlue.ObtainInstanceForProxyTypeName), new Type[] { typeof(string), typeof(object) }); /********* ** Fields @@ -28,6 +29,8 @@ namespace StardewModdingAPI.Framework.Reflection /// The generated proxy type. private Type ProxyType; + /// A cache of all proxies generated by this builder. + private readonly ConditionalWeakTable ProxyCache = new(); /********* ** Public methods @@ -168,15 +171,20 @@ namespace StardewModdingAPI.Framework.Reflection this.ProxyType = proxyBuilder.CreateType(); } - /// Create an instance of the proxy for a target instance. + /// Get an existing or create a new instance of the proxy for a target instance. /// The target instance. /// The that requested to build a proxy. - public object CreateInstance(object targetInstance, InterfaceProxyFactory factory) + public object ObtainInstance(object targetInstance, InterfaceProxyFactory factory) { + if (this.ProxyCache.TryGetValue(targetInstance, out object proxyInstance)) + return proxyInstance; + ConstructorInfo constructor = this.ProxyType.GetConstructor(new[] { this.TargetType, typeof(InterfaceProxyGlue) }); if (constructor == null) throw new InvalidOperationException($"Couldn't find the constructor for generated proxy type '{this.ProxyType.Name}'."); // should never happen - return constructor.Invoke(new[] { targetInstance, new InterfaceProxyGlue(factory) }); + proxyInstance = constructor.Invoke(new[] { targetInstance, new InterfaceProxyGlue(factory) }); + this.ProxyCache.Add(targetInstance, proxyInstance); + return proxyInstance; } @@ -297,7 +305,7 @@ namespace StardewModdingAPI.Framework.Reflection il.Emit(OpCodes.Ldfld, glueField); il.Emit(OpCodes.Ldstr, proxyTypeName); il.Emit(OpCodes.Ldloc, inputLocal); - il.Emit(OpCodes.Call, CreateInstanceForProxyTypeNameMethod); + il.Emit(OpCodes.Call, ObtainInstanceForProxyTypeNameMethod); il.Emit(OpCodes.Castclass, outputLocal.LocalType); il.Emit(OpCodes.Stloc, outputLocal); diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs index 72b4254c..daeac2ad 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs @@ -42,7 +42,7 @@ namespace StardewModdingAPI.Framework.Reflection // create instance InterfaceProxyBuilder builder = this.ObtainBuilder(instance.GetType(), typeof(TInterface), sourceModID, targetModID); - return (TInterface)builder.CreateInstance(instance, this); + return (TInterface)builder.ObtainInstance(instance, this); } internal InterfaceProxyBuilder ObtainBuilder(Type targetType, Type interfaceType, string sourceModID, string targetModID) diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs index 8d0d74a7..f98b54a2 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs @@ -10,13 +10,13 @@ namespace StardewModdingAPI.Framework.Reflection this.Factory = factory; } - /// Creates a new proxied instance by its type name. + /// Get an existing or create a new proxied instance by its type name. /// The full name of the proxy type. /// The target instance to proxy. - public object CreateInstanceForProxyTypeName(string proxyTypeName, object toProxy) + public object ObtainInstanceForProxyTypeName(string proxyTypeName, object toProxy) { var builder = this.Factory.GetBuilderByProxyTypeName(proxyTypeName); - return builder.CreateInstance(toProxy, this.Factory); + return builder.ObtainInstance(toProxy, this.Factory); } } } -- cgit From 467375a7a37f5ed3012d1786d3149a0dddd28871 Mon Sep 17 00:00:00 2001 From: Shockah Date: Thu, 10 Feb 2022 14:15:06 +0100 Subject: add reverse API proxying (and unproxying) --- .../Framework/Reflection/InterfaceProxyBuilder.cs | 166 +++++++++++++-------- .../Framework/Reflection/InterfaceProxyFactory.cs | 4 +- .../Framework/Reflection/InterfaceProxyGlue.cs | 12 ++ 3 files changed, 120 insertions(+), 62 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs index 63a594fa..81cfff50 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs @@ -16,6 +16,7 @@ namespace StardewModdingAPI.Framework.Reflection private static readonly string TargetFieldName = "__Target"; private static readonly string GlueFieldName = "__Glue"; private static readonly MethodInfo ObtainInstanceForProxyTypeNameMethod = typeof(InterfaceProxyGlue).GetMethod(nameof(InterfaceProxyGlue.ObtainInstanceForProxyTypeName), new Type[] { typeof(string), typeof(object) }); + private static readonly MethodInfo UnproxyOrObtainInstanceForProxyTypeNameMethod = typeof(InterfaceProxyGlue).GetMethod(nameof(InterfaceProxyGlue.UnproxyOrObtainInstanceForProxyTypeName), new Type[] { typeof(string), typeof(string), typeof(object) }); /********* ** Fields @@ -23,6 +24,9 @@ namespace StardewModdingAPI.Framework.Reflection /// The target class type. private readonly Type TargetType; + /// The interfce type. + private readonly Type InterfaceType; + /// The full name of the generated proxy type. private readonly string ProxyTypeName; @@ -37,26 +41,29 @@ namespace StardewModdingAPI.Framework.Reflection *********/ /// Construct an instance. /// The target type. + /// The interface type to implement. /// The type name to generate. - public InterfaceProxyBuilder(Type targetType, string proxyTypeName) + public InterfaceProxyBuilder(Type targetType, Type interfaceType, string proxyTypeName) { - // validate + // validate and store this.TargetType = targetType ?? throw new ArgumentNullException(nameof(targetType)); + this.InterfaceType = interfaceType ?? throw new ArgumentNullException(nameof(interfaceType)); this.ProxyTypeName = proxyTypeName ?? throw new ArgumentNullException(nameof(proxyTypeName)); + if (!interfaceType.IsInterface) + throw new ArgumentException($"{nameof(interfaceType)} is not an interface."); } /// Creates and sets up the proxy type. /// The that requested to build a proxy. /// The CLR module in which to create proxy classes. - /// The interface type to implement. /// The unique ID of the mod consuming the API. /// The unique ID of the mod providing the API. - public void SetupProxyType(InterfaceProxyFactory factory, ModuleBuilder moduleBuilder, Type interfaceType, string sourceModID, string targetModID) + public void SetupProxyType(InterfaceProxyFactory factory, ModuleBuilder moduleBuilder, string sourceModID, string targetModID) { // define proxy type TypeBuilder proxyBuilder = moduleBuilder.DefineType(this.ProxyTypeName, TypeAttributes.Public | TypeAttributes.Class); - proxyBuilder.AddInterfaceImplementation(interfaceType); + proxyBuilder.AddInterfaceImplementation(this.InterfaceType); // create fields to store target instance and proxy factory FieldBuilder targetField = proxyBuilder.DefineField(TargetFieldName, this.TargetType, FieldAttributes.Private); @@ -100,13 +107,19 @@ namespace StardewModdingAPI.Framework.Reflection if (typeA.IsGenericMethodParameter ? typeA.GenericParameterPosition == typeB.GenericParameterPosition : typeA.IsAssignableFrom(typeB)) return MatchingTypesResult.True; - if (!proxyType.IsGenericMethodParameter && proxyType.GetNonRefType().IsInterface && proxyType.Assembly == interfaceType.Assembly) - return MatchingTypesResult.IfProxied; + if (!proxyType.IsGenericMethodParameter) + { + if (proxyType.GetNonRefType().IsInterface) + return MatchingTypesResult.IfProxied; + if (targetType.GetNonRefType().IsInterface) + return MatchingTypesResult.IfProxied; + } + return MatchingTypesResult.False; } // proxy methods - foreach (MethodInfo proxyMethod in interfaceType.GetMethods()) + foreach (MethodInfo proxyMethod in this.InterfaceType.GetMethods()) { var proxyMethodParameters = proxyMethod.GetParameters(); var proxyMethodGenericArguments = proxyMethod.GetGenericArguments(); @@ -144,15 +157,8 @@ namespace StardewModdingAPI.Framework.Reflection case MatchingTypesResult.True: break; case MatchingTypesResult.IfProxied: - if (proxyMethodParameters[i].IsOut) - { - positionsToProxy.Add(i); - break; - } - else - { - goto targetMethodLoopContinue; - } + positionsToProxy.Add(i); + break; } } @@ -163,7 +169,7 @@ namespace StardewModdingAPI.Framework.Reflection targetMethodLoopContinue:; } - throw new InvalidOperationException($"The {interfaceType.FullName} interface defines method {proxyMethod.Name} which doesn't exist in the API."); + throw new InvalidOperationException($"The {this.InterfaceType.FullName} interface defines method {proxyMethod.Name} which doesn't exist in the API."); proxyMethodLoopContinue:; } @@ -225,7 +231,8 @@ namespace StardewModdingAPI.Framework.Reflection // proxy additional types string returnValueProxyTypeName = null; - string[] parameterProxyTypeNames = new string[argTypes.Length]; + string[] parameterTargetToArgProxyTypeNames = new string[argTypes.Length]; + string[] parameterArgToTargetUnproxyTypeNames = new string[argTypes.Length]; if (positionsToProxy.Count > 0) { foreach (int? position in positionsToProxy) @@ -240,15 +247,18 @@ namespace StardewModdingAPI.Framework.Reflection else // it's one of the parameters { bool isByRef = argTypes[position.Value].IsByRef; - var targetType = targetParameters[position.Value].ParameterType.GetNonRefType(); - var argType = argTypes[position.Value].GetNonRefType(); - - var builder = factory.ObtainBuilder(targetType, argType, sourceModID, targetModID); - if (isByRef) - argType = argType.MakeByRefType(); + var targetType = targetParameters[position.Value].ParameterType; + var argType = argTypes[position.Value]; + var builder = factory.ObtainBuilder(targetType.GetNonRefType(), argType.GetNonRefType(), sourceModID, targetModID); argTypes[position.Value] = argType; - parameterProxyTypeNames[position.Value] = builder.ProxyTypeName; + parameterTargetToArgProxyTypeNames[position.Value] = builder.ProxyTypeName; + + if (!targetParameters[position.Value].IsOut) + { + var argToTargetBuilder = factory.ObtainBuilder(argType.GetNonRefType(), targetType.GetNonRefType(), sourceModID, targetModID); + parameterArgToTargetUnproxyTypeNames[position.Value] = argToTargetBuilder.ProxyTypeName; + } } } @@ -262,33 +272,10 @@ namespace StardewModdingAPI.Framework.Reflection // create method body { ILGenerator il = methodBuilder.GetILGenerator(); - LocalBuilder[] outInputLocals = new LocalBuilder[argTypes.Length]; - LocalBuilder[] outOutputLocals = new LocalBuilder[argTypes.Length]; + LocalBuilder[] inputLocals = new LocalBuilder[argTypes.Length]; + LocalBuilder[] outputLocals = new LocalBuilder[argTypes.Length]; - // calling the proxied method - LocalBuilder resultInputLocal = target.ReturnType == typeof(void) ? null : il.DeclareLocal(target.ReturnType); - LocalBuilder resultOutputLocal = returnType == typeof(void) ? null : il.DeclareLocal(returnType); - il.Emit(OpCodes.Ldarg_0); - il.Emit(OpCodes.Ldfld, instanceField); - for (int i = 0; i < argTypes.Length; i++) - { - if (parameterProxyTypeNames[i] == null) - { - il.Emit(OpCodes.Ldarg, i + 1); - } - else - { - // previous code already checks if the parameters are specifically `out` - outInputLocals[i] = il.DeclareLocal(targetParameters[i].ParameterType.GetNonRefType()); - outOutputLocals[i] = il.DeclareLocal(argTypes[i].GetNonRefType()); - il.Emit(OpCodes.Ldloca, outInputLocals[i]); - } - } - il.Emit(target.IsVirtual ? OpCodes.Callvirt : OpCodes.Call, target); - if (target.ReturnType != typeof(void)) - il.Emit(OpCodes.Stloc, resultInputLocal); - - void ProxyIfNeededAndStore(LocalBuilder inputLocal, LocalBuilder outputLocal, string proxyTypeName) + void ProxyIfNeededAndStore(LocalBuilder inputLocal, LocalBuilder outputLocal, string proxyTypeName, string unproxyTypeName) { if (proxyTypeName == null) { @@ -303,31 +290,73 @@ namespace StardewModdingAPI.Framework.Reflection il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ldfld, glueField); - il.Emit(OpCodes.Ldstr, proxyTypeName); - il.Emit(OpCodes.Ldloc, inputLocal); - il.Emit(OpCodes.Call, ObtainInstanceForProxyTypeNameMethod); + if (unproxyTypeName == null) + { + il.Emit(OpCodes.Ldstr, proxyTypeName); + il.Emit(OpCodes.Ldloc, inputLocal); + il.Emit(OpCodes.Call, ObtainInstanceForProxyTypeNameMethod); + } + else + { + il.Emit(OpCodes.Ldstr, proxyTypeName); + il.Emit(OpCodes.Ldstr, unproxyTypeName); + il.Emit(OpCodes.Ldloc, inputLocal); + il.Emit(OpCodes.Call, UnproxyOrObtainInstanceForProxyTypeNameMethod); + } il.Emit(OpCodes.Castclass, outputLocal.LocalType); il.Emit(OpCodes.Stloc, outputLocal); il.MarkLabel(isNullLabel); } + // calling the proxied method + LocalBuilder resultInputLocal = target.ReturnType == typeof(void) ? null : il.DeclareLocal(target.ReturnType); + LocalBuilder resultOutputLocal = returnType == typeof(void) ? null : il.DeclareLocal(returnType); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, instanceField); + for (int i = 0; i < argTypes.Length; i++) + { + if (targetParameters[i].IsOut && parameterTargetToArgProxyTypeNames[i] != null) // out parameter, proxy on the way back + { + inputLocals[i] = il.DeclareLocal(targetParameters[i].ParameterType.GetNonRefType()); + outputLocals[i] = il.DeclareLocal(argTypes[i].GetNonRefType()); + il.Emit(OpCodes.Ldloca, inputLocals[i]); + } + else if (parameterArgToTargetUnproxyTypeNames[i] != null) // normal parameter, proxy on the way in + { + inputLocals[i] = il.DeclareLocal(argTypes[i].GetNonRefType()); + outputLocals[i] = il.DeclareLocal(targetParameters[i].ParameterType.GetNonRefType()); + il.Emit(OpCodes.Ldarg, i + 1); + il.Emit(OpCodes.Stloc, inputLocals[i]); + ProxyIfNeededAndStore(inputLocals[i], outputLocals[i], parameterArgToTargetUnproxyTypeNames[i], parameterTargetToArgProxyTypeNames[i]); + il.Emit(OpCodes.Ldloc, outputLocals[i]); + } + else // normal parameter, no proxying + { + il.Emit(OpCodes.Ldarg, i + 1); + } + } + il.Emit(target.IsVirtual ? OpCodes.Callvirt : OpCodes.Call, target); + if (target.ReturnType != typeof(void)) + il.Emit(OpCodes.Stloc, resultInputLocal); + // proxying `out` parameters for (int i = 0; i < argTypes.Length; i++) { - if (parameterProxyTypeNames[i] == null) + if (parameterTargetToArgProxyTypeNames[i] == null) + continue; + if (!targetParameters[i].IsOut) continue; - // previous code already checks if the parameters are specifically `out` - ProxyIfNeededAndStore(outInputLocals[i], outOutputLocals[i], parameterProxyTypeNames[i]); + ProxyIfNeededAndStore(inputLocals[i], outputLocals[i], parameterTargetToArgProxyTypeNames[i], null); il.Emit(OpCodes.Ldarg, i + 1); - il.Emit(OpCodes.Ldloc, outOutputLocals[i]); + il.Emit(OpCodes.Ldloc, outputLocals[i]); il.Emit(OpCodes.Stind_Ref); } // proxying return value if (target.ReturnType != typeof(void)) - ProxyIfNeededAndStore(resultInputLocal, resultOutputLocal, returnValueProxyTypeName); + ProxyIfNeededAndStore(resultInputLocal, resultOutputLocal, returnValueProxyTypeName, null); // return result if (target.ReturnType != typeof(void)) @@ -336,6 +365,23 @@ namespace StardewModdingAPI.Framework.Reflection } } + /// Try to get a target instance for a given proxy instance. + /// The proxy instance to look for. + /// The reference to store the found target instance in. + public bool TryUnproxy(object potentialProxyInstance, out object targetInstance) + { + foreach ((object cachedTargetInstance, object cachedProxyInstance) in this.ProxyCache) + { + if (object.ReferenceEquals(potentialProxyInstance, cachedProxyInstance)) + { + targetInstance = cachedTargetInstance; + return true; + } + } + targetInstance = null; + return false; + } + /// The part of a method that is being matched. private enum MethodTypeMatchingPart { diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs index daeac2ad..a6f38c3a 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs @@ -57,11 +57,11 @@ namespace StardewModdingAPI.Framework.Reflection string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{interfaceType.FullName}>_To<{targetModID}_{targetType.FullName}>"; if (!this.Builders.TryGetValue(proxyTypeName, out InterfaceProxyBuilder builder)) { - builder = new InterfaceProxyBuilder(targetType, proxyTypeName); + builder = new InterfaceProxyBuilder(targetType, interfaceType, proxyTypeName); this.Builders[proxyTypeName] = builder; try { - builder.SetupProxyType(this, this.ModuleBuilder, interfaceType, sourceModID, targetModID); + builder.SetupProxyType(this, this.ModuleBuilder, sourceModID, targetModID); } catch { diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs index f98b54a2..38569efa 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyGlue.cs @@ -18,5 +18,17 @@ namespace StardewModdingAPI.Framework.Reflection var builder = this.Factory.GetBuilderByProxyTypeName(proxyTypeName); return builder.ObtainInstance(toProxy, this.Factory); } + + /// Try to unproxy, or get an existing, or create a new proxied instance by its type name. + /// The full name of the proxy type. + /// The full name of the reverse proxy type. + /// The target instance to proxy. + public object UnproxyOrObtainInstanceForProxyTypeName(string proxyTypeName, string unproxyTypeName, object toProxy) + { + var unproxyBuilder = this.Factory.GetBuilderByProxyTypeName(unproxyTypeName); + if (unproxyBuilder.TryUnproxy(toProxy, out object targetInstance)) + return targetInstance; + return this.ObtainInstanceForProxyTypeName(proxyTypeName, toProxy); + } } } -- cgit From a54d58d064df5654e845f9b2b4fc9db5b0189db0 Mon Sep 17 00:00:00 2001 From: Shockah Date: Thu, 10 Feb 2022 16:26:43 +0100 Subject: add TryProxy for any objects --- .../Framework/ModHelpers/ModRegistryHelper.cs | 26 ++++++++++++++++++++++ src/SMAPI/IModRegistry.cs | 7 ++++++ 2 files changed, 33 insertions(+) (limited to 'src') diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs index ef1ad30c..92c52b00 100644 --- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -98,5 +98,31 @@ namespace StardewModdingAPI.Framework.ModHelpers return castApi; return this.ProxyFactory.CreateProxy(api, this.ModID, uniqueID); } + + /// + public bool TryProxy(string uniqueID, object toProxy, out TInterface proxy) where TInterface : class + { + try + { + foreach (var toProxyInterface in toProxy.GetType().GetInterfaces()) + { + var unproxyBuilder = this.ProxyFactory.ObtainBuilder(typeof(TInterface), toProxyInterface, this.ModID, uniqueID); + if (unproxyBuilder.TryUnproxy(toProxy, out object targetInstance)) + { + proxy = (TInterface)targetInstance; + return true; + } + } + + var proxyBuilder = this.ProxyFactory.ObtainBuilder(toProxy.GetType(), typeof(TInterface), this.ModID, uniqueID); + proxy = (TInterface)proxyBuilder.ObtainInstance(toProxy, this.ProxyFactory); + return true; + } + catch + { + proxy = null; + return false; + } + } } } diff --git a/src/SMAPI/IModRegistry.cs b/src/SMAPI/IModRegistry.cs index 10b3121e..9b99e459 100644 --- a/src/SMAPI/IModRegistry.cs +++ b/src/SMAPI/IModRegistry.cs @@ -25,5 +25,12 @@ namespace StardewModdingAPI /// The interface which matches the properties and methods you intend to access. /// The mod's unique ID. TInterface GetApi(string uniqueID) where TInterface : class; + + /// Try to proxy (or unproxy back) the given object to a given interface provided by a mod. + /// The interface type to proxy (or unproxy) to. + /// The mod's unique ID. + /// The object to try to proxy (or unproxy back). + /// The reference to store the proxied (or unproxied) object back. + bool TryProxy(string uniqueID, object toProxy, out TInterface proxy) where TInterface : class; } } -- cgit From 3064b58719060a145058ab295792d8c97128b433 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Thu, 10 Feb 2022 22:03:09 -0500 Subject: add basic unit tests for API interface proxying --- src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs | 46 +++ .../Interfaces/ISimpleApi.cs | 79 +++++ src/SMAPI.Tests.ModApiConsumer/README.md | 3 + .../SMAPI.Tests.ModApiConsumer.csproj | 11 + .../Framework/BaseApi.cs | 12 + .../Framework/SimpleApi.cs | 108 +++++++ src/SMAPI.Tests.ModApiProvider/ProviderMod.cs | 38 +++ src/SMAPI.Tests.ModApiProvider/README.md | 3 + .../SMAPI.Tests.ModApiProvider.csproj | 7 + src/SMAPI.Tests/Core/InterfaceProxyTests.cs | 345 +++++++++++++++++++++ src/SMAPI.Tests/SMAPI.Tests.csproj | 7 +- src/SMAPI.sln | 14 + 12 files changed, 669 insertions(+), 4 deletions(-) create mode 100644 src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs create mode 100644 src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs create mode 100644 src/SMAPI.Tests.ModApiConsumer/README.md create mode 100644 src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj create mode 100644 src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs create mode 100644 src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs create mode 100644 src/SMAPI.Tests.ModApiProvider/ProviderMod.cs create mode 100644 src/SMAPI.Tests.ModApiProvider/README.md create mode 100644 src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj create mode 100644 src/SMAPI.Tests/Core/InterfaceProxyTests.cs (limited to 'src') diff --git a/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs b/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs new file mode 100644 index 00000000..2c7f9952 --- /dev/null +++ b/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs @@ -0,0 +1,46 @@ +using System; +using SMAPI.Tests.ModApiConsumer.Interfaces; + +namespace SMAPI.Tests.ModApiConsumer +{ + /// A simulated API consumer. + public class ApiConsumer + { + /********* + ** Public methods + *********/ + /// Call the event field on the given API. + /// The API to call. + /// Get the number of times the event was called and the last value received. + public void UseEventField(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues) + { + // act + int calls = 0; + int lastValue = -1; + api.OnEventRaised += (sender, value) => + { + calls++; + lastValue = value; + }; + + getValues = () => (timesCalled: calls, actualValue: lastValue); + } + + /// Call the event property on the given API. + /// The API to call. + /// Get the number of times the event was called and the last value received. + public void UseEventProperty(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues) + { + // act + int calls = 0; + int lastValue = -1; + api.OnEventRaisedProperty += (sender, value) => + { + calls++; + lastValue = value; + }; + + getValues = () => (timesCalled: calls, actualValue: lastValue); + } + } +} diff --git a/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs b/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs new file mode 100644 index 00000000..7f94e137 --- /dev/null +++ b/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace SMAPI.Tests.ModApiConsumer.Interfaces +{ + /// A mod-provided API which provides basic events, properties, and methods. + public interface ISimpleApi + { + /********* + ** Test interface + *********/ + /**** + ** Events + ****/ + /// A simple event field. + event EventHandler OnEventRaised; + + /// A simple event property with custom add/remove logic. + event EventHandler OnEventRaisedProperty; + + + /**** + ** Properties + ****/ + /// A simple numeric property. + int NumberProperty { get; set; } + + /// A simple object property. + object ObjectProperty { get; set; } + + /// A simple list property. + List ListProperty { get; set; } + + /// A simple list property with an interface. + IList ListPropertyWithInterface { get; set; } + + /// A property with nested generics. + IDictionary> GenericsProperty { get; set; } + + /// A property using an enum available to both mods. + BindingFlags EnumProperty { get; set; } + + /// A read-only property. + int GetterProperty { get; } + + + /**** + ** Methods + ****/ + /// A simple method with no return value. + void GetNothing(); + + /// A simple method which returns a number. + int GetInt(int value); + + /// A simple method which returns an object. + object GetObject(object value); + + /// A simple method which returns a list. + List GetList(string value); + + /// A simple method which returns a list with an interface. + IList GetListWithInterface(string value); + + /// A simple method which returns nested generics. + IDictionary> GetGenerics(string key, string value); + + /// A simple method which returns a lambda. + Func GetLambda(Func value); + + + /**** + ** Inherited members + ****/ + /// A property inherited from a base class. + public string InheritedProperty { get; set; } + } +} diff --git a/src/SMAPI.Tests.ModApiConsumer/README.md b/src/SMAPI.Tests.ModApiConsumer/README.md new file mode 100644 index 00000000..ed0c6e3f --- /dev/null +++ b/src/SMAPI.Tests.ModApiConsumer/README.md @@ -0,0 +1,3 @@ +This project contains a simulated [mod-provided API] consumer used in the API proxying unit tests. + +[mod-provided API]: https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations diff --git a/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj b/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj new file mode 100644 index 00000000..7fef4ebd --- /dev/null +++ b/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj @@ -0,0 +1,11 @@ + + + net5.0 + + + + + + + + diff --git a/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs b/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs new file mode 100644 index 00000000..8092e3e7 --- /dev/null +++ b/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs @@ -0,0 +1,12 @@ +namespace SMAPI.Tests.ModApiProvider.Framework +{ + /// The base class for . + public class BaseApi + { + /********* + ** Test interface + *********/ + /// A property inherited from a base class. + public string InheritedProperty { get; set; } + } +} diff --git a/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs b/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs new file mode 100644 index 00000000..1100af36 --- /dev/null +++ b/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace SMAPI.Tests.ModApiProvider.Framework +{ + /// A mod-provided API which provides basic events, properties, and methods. + public class SimpleApi : BaseApi + { + /********* + ** Test interface + *********/ + /**** + ** Events + ****/ + /// A simple event field. + public event EventHandler OnEventRaised; + + /// A simple event property with custom add/remove logic. + public event EventHandler OnEventRaisedProperty + { + add => this.OnEventRaised += value; + remove => this.OnEventRaised -= value; + } + + + /**** + ** Properties + ****/ + /// A simple numeric property. + public int NumberProperty { get; set; } + + /// A simple object property. + public object ObjectProperty { get; set; } + + /// A simple list property. + public List ListProperty { get; set; } + + /// A simple list property with an interface. + public IList ListPropertyWithInterface { get; set; } + + /// A property with nested generics. + public IDictionary> GenericsProperty { get; set; } + + /// A property using an enum available to both mods. + public BindingFlags EnumProperty { get; set; } + + /// A read-only property. + public int GetterProperty => 42; + + + /**** + ** Methods + ****/ + /// A simple method with no return value. + public void GetNothing() { } + + /// A simple method which returns a number. + public int GetInt(int value) + { + return value; + } + + /// A simple method which returns an object. + public object GetObject(object value) + { + return value; + } + + /// A simple method which returns a list. + public List GetList(string value) + { + return new() { value }; + } + + /// A simple method which returns a list with an interface. + public IList GetListWithInterface(string value) + { + return new List { value }; + } + + /// A simple method which returns nested generics. + public IDictionary> GetGenerics(string key, string value) + { + return new Dictionary> + { + [key] = new List { value } + }; + } + + /// A simple method which returns a lambda. + public Func GetLambda(Func value) + { + return value; + } + + + /********* + ** Helper methods + *********/ + /// Raise the event. + /// The value to pass to the event. + public void RaiseEventField(int value) + { + this.OnEventRaised?.Invoke(null, value); + } + } +} diff --git a/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs b/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs new file mode 100644 index 00000000..c36e1c6d --- /dev/null +++ b/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Reflection; +using SMAPI.Tests.ModApiProvider.Framework; + +namespace SMAPI.Tests.ModApiProvider +{ + /// A simulated mod instance. + public class ProviderMod + { + /// The underlying API instance. + private readonly SimpleApi Api = new(); + + /// Get the mod API instance. + public object GetModApi() + { + return this.Api; + } + + /// Raise the event. + /// The value to send as an event argument. + public void RaiseEvent(int value) + { + this.Api.RaiseEventField(value); + } + + /// Set the values for the API property. + public void SetPropertyValues(int number, object obj, string listValue, string listWithInterfaceValue, string dictionaryKey, string dictionaryListValue, BindingFlags enumValue, string inheritedValue) + { + this.Api.NumberProperty = number; + this.Api.ObjectProperty = obj; + this.Api.ListProperty = new List { listValue }; + this.Api.ListPropertyWithInterface = new List { listWithInterfaceValue }; + this.Api.GenericsProperty = new Dictionary> { [dictionaryKey] = new List { dictionaryListValue } }; + this.Api.EnumProperty = enumValue; + this.Api.InheritedProperty = inheritedValue; + } + } +} diff --git a/src/SMAPI.Tests.ModApiProvider/README.md b/src/SMAPI.Tests.ModApiProvider/README.md new file mode 100644 index 00000000..c79838e0 --- /dev/null +++ b/src/SMAPI.Tests.ModApiProvider/README.md @@ -0,0 +1,3 @@ +This project contains simulated [mod-provided APIs] used in the API proxying unit tests. + +[mod-provided APIs]: https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations diff --git a/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj new file mode 100644 index 00000000..70d5a0ce --- /dev/null +++ b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj @@ -0,0 +1,7 @@ + + + net5.0 + + + + diff --git a/src/SMAPI.Tests/Core/InterfaceProxyTests.cs b/src/SMAPI.Tests/Core/InterfaceProxyTests.cs new file mode 100644 index 00000000..99c1298f --- /dev/null +++ b/src/SMAPI.Tests/Core/InterfaceProxyTests.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using FluentAssertions; +using NUnit.Framework; +using SMAPI.Tests.ModApiConsumer; +using SMAPI.Tests.ModApiConsumer.Interfaces; +using SMAPI.Tests.ModApiProvider; +using StardewModdingAPI.Framework.Reflection; + +namespace SMAPI.Tests.Core +{ + /// Unit tests for . + [TestFixture] + internal class InterfaceProxyTests + { + /********* + ** Fields + *********/ + /// The mod ID providing an API. + private readonly string FromModId = "From.ModId"; + + /// The mod ID consuming an API. + private readonly string ToModId = "From.ModId"; + + /// The random number generator with which to create sample values. + private readonly Random Random = new(); + + + /********* + ** Unit tests + *********/ + /**** + ** Events + ****/ + /// Assert that an event field can be proxied correctly. + [Test] + public void CanProxy_EventField() + { + // arrange + var providerMod = new ProviderMod(); + object implementation = providerMod.GetModApi(); + int expectedValue = this.Random.Next(); + + // act + ISimpleApi proxy = this.GetProxy(implementation); + new ApiConsumer().UseEventField(proxy, out Func<(int timesCalled, int lastValue)> getValues); + providerMod.RaiseEvent(expectedValue); + (int timesCalled, int lastValue) = getValues(); + + // assert + timesCalled.Should().Be(1, "Expected the proxied event to be raised once."); + lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised."); + } + + /// Assert that an event property can be proxied correctly. + [Test] + public void CanProxy_EventProperty() + { + // arrange + var providerMod = new ProviderMod(); + object implementation = providerMod.GetModApi(); + int expectedValue = this.Random.Next(); + + // act + ISimpleApi proxy = this.GetProxy(implementation); + new ApiConsumer().UseEventProperty(proxy, out Func<(int timesCalled, int lastValue)> getValues); + providerMod.RaiseEvent(expectedValue); + (int timesCalled, int lastValue) = getValues(); + + // assert + timesCalled.Should().Be(1, "Expected the proxied event to be raised once."); + lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised."); + } + + /**** + ** Properties + ****/ + /// Assert that properties can be proxied correctly. + /// Whether to set the properties through the provider mod or proxy interface. + [TestCase("set via provider mod")] + [TestCase("set via proxy interface")] + public void CanProxy_Properties(string setVia) + { + // arrange + var providerMod = new ProviderMod(); + object implementation = providerMod.GetModApi(); + int expectedNumber = this.Random.Next(); + int expectedObject = this.Random.Next(); + string expectedListValue = this.GetRandomString(); + string expectedListWithInterfaceValue = this.GetRandomString(); + string expectedDictionaryKey = this.GetRandomString(); + string expectedDictionaryListValue = this.GetRandomString(); + string expectedInheritedString = this.GetRandomString(); + BindingFlags expectedEnum = BindingFlags.Instance | BindingFlags.Public; + + // act + ISimpleApi proxy = this.GetProxy(implementation); + switch (setVia) + { + case "set via provider mod": + providerMod.SetPropertyValues( + number: expectedNumber, + obj: expectedObject, + listValue: expectedListValue, + listWithInterfaceValue: expectedListWithInterfaceValue, + dictionaryKey: expectedDictionaryKey, + dictionaryListValue: expectedDictionaryListValue, + enumValue: expectedEnum, + inheritedValue: expectedInheritedString + ); + break; + + case "set via proxy interface": + proxy.NumberProperty = expectedNumber; + proxy.ObjectProperty = expectedObject; + proxy.ListProperty = new() { expectedListValue }; + proxy.ListPropertyWithInterface = new List { expectedListWithInterfaceValue }; + proxy.GenericsProperty = new Dictionary> + { + [expectedDictionaryKey] = new List { expectedDictionaryListValue } + }; + proxy.EnumProperty = expectedEnum; + proxy.InheritedProperty = expectedInheritedString; + break; + + default: + throw new InvalidOperationException($"Invalid 'set via' option '{setVia}."); + } + + // assert number + this + .GetPropertyValue(implementation, nameof(proxy.NumberProperty)) + .Should().Be(expectedNumber); + proxy.NumberProperty + .Should().Be(expectedNumber); + + // assert object + this + .GetPropertyValue(implementation, nameof(proxy.ObjectProperty)) + .Should().Be(expectedObject); + proxy.ObjectProperty + .Should().Be(expectedObject); + + // assert list + (this.GetPropertyValue(implementation, nameof(proxy.ListProperty)) as IList) + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListValue); + proxy.ListProperty + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListValue); + + // assert list with interface + (this.GetPropertyValue(implementation, nameof(proxy.ListPropertyWithInterface)) as IList) + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListWithInterfaceValue); + proxy.ListPropertyWithInterface + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListWithInterfaceValue); + + // assert generics + (this.GetPropertyValue(implementation, nameof(proxy.GenericsProperty)) as IDictionary>) + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue); + proxy.GenericsProperty + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue); + + // assert enum + this + .GetPropertyValue(implementation, nameof(proxy.EnumProperty)) + .Should().Be(expectedEnum); + proxy.EnumProperty + .Should().Be(expectedEnum); + + // assert getter + this + .GetPropertyValue(implementation, nameof(proxy.GetterProperty)) + .Should().Be(42); + proxy.GetterProperty + .Should().Be(42); + + // assert inherited methods + this + .GetPropertyValue(implementation, nameof(proxy.InheritedProperty)) + .Should().Be(expectedInheritedString); + proxy.InheritedProperty + .Should().Be(expectedInheritedString); + } + + /// Assert that a simple method with no return value can be proxied correctly. + [Test] + public void CanProxy_SimpleMethod_Void() + { + // arrange + object implementation = new ProviderMod().GetModApi(); + + // act + ISimpleApi proxy = this.GetProxy(implementation); + proxy.GetNothing(); + } + + /// Assert that a simple int method can be proxied correctly. + [Test] + public void CanProxy_SimpleMethod_Int() + { + // arrange + object implementation = new ProviderMod().GetModApi(); + int expectedValue = this.Random.Next(); + + // act + ISimpleApi proxy = this.GetProxy(implementation); + int actualValue = proxy.GetInt(expectedValue); + + // assert + actualValue.Should().Be(expectedValue); + } + + /// Assert that a simple object method can be proxied correctly. + [Test] + public void CanProxy_SimpleMethod_Object() + { + // arrange + object implementation = new ProviderMod().GetModApi(); + object expectedValue = new(); + + // act + ISimpleApi proxy = this.GetProxy(implementation); + object actualValue = proxy.GetObject(expectedValue); + + // assert + actualValue.Should().BeSameAs(expectedValue); + } + + /// Assert that a simple list method can be proxied correctly. + [Test] + public void CanProxy_SimpleMethod_List() + { + // arrange + object implementation = new ProviderMod().GetModApi(); + string expectedValue = this.GetRandomString(); + + // act + ISimpleApi proxy = this.GetProxy(implementation); + IList actualValue = proxy.GetList(expectedValue); + + // assert + actualValue.Should().BeEquivalentTo(expectedValue); + } + + /// Assert that a simple list with interface method can be proxied correctly. + [Test] + public void CanProxy_SimpleMethod_ListWithInterface() + { + // arrange + object implementation = new ProviderMod().GetModApi(); + string expectedValue = this.GetRandomString(); + + // act + ISimpleApi proxy = this.GetProxy(implementation); + IList actualValue = proxy.GetListWithInterface(expectedValue); + + // assert + actualValue.Should().BeEquivalentTo(expectedValue); + } + + /// Assert that a simple method which returns generic types can be proxied correctly. + [Test] + public void CanProxy_SimpleMethod_GenericTypes() + { + // arrange + object implementation = new ProviderMod().GetModApi(); + string expectedKey = this.GetRandomString(); + string expectedValue = this.GetRandomString(); + + // act + ISimpleApi proxy = this.GetProxy(implementation); + IDictionary> actualValue = proxy.GetGenerics(expectedKey, expectedValue); + + // assert + actualValue + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainKey(expectedKey).WhoseValue.Should().BeEquivalentTo(expectedValue); + } + + /// Assert that a simple lambda method can be proxied correctly. + [Test] + [SuppressMessage("ReSharper", "ConvertToLocalFunction")] + public void CanProxy_SimpleMethod_Lambda() + { + // arrange + object implementation = new ProviderMod().GetModApi(); + Func expectedValue = _ => "test"; + + // act + ISimpleApi proxy = this.GetProxy(implementation); + object actualValue = proxy.GetObject(expectedValue); + + // assert + actualValue.Should().BeSameAs(expectedValue); + } + + + /********* + ** Private methods + *********/ + /// Get a property value from an instance. + /// The instance whose property to read. + /// The property name. + private object GetPropertyValue(object parent, string name) + { + if (parent is null) + throw new ArgumentNullException(nameof(parent)); + + Type type = parent.GetType(); + PropertyInfo property = type.GetProperty(name); + if (property is null) + throw new InvalidOperationException($"The '{type.FullName}' type has no public property named '{name}'."); + + return property.GetValue(parent); + } + + /// Get a random test string. + private string GetRandomString() + { + return this.Random.Next().ToString(); + } + + /// Get a proxy API instance. + /// The underlying API instance. + private ISimpleApi GetProxy(object implementation) + { + var proxyFactory = new InterfaceProxyFactory(); + return proxyFactory.CreateProxy(implementation, this.FromModId, this.ToModId); + } + } +} diff --git a/src/SMAPI.Tests/SMAPI.Tests.csproj b/src/SMAPI.Tests/SMAPI.Tests.csproj index 8329b2e1..67997b30 100644 --- a/src/SMAPI.Tests/SMAPI.Tests.csproj +++ b/src/SMAPI.Tests/SMAPI.Tests.csproj @@ -1,21 +1,20 @@  - SMAPI.Tests - SMAPI.Tests net5.0 - false - latest + + + diff --git a/src/SMAPI.sln b/src/SMAPI.sln index be5326f7..d9f60a5c 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -101,6 +101,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "lib", "lib", "{3B5BF14D-F61 ..\build\windows\lib\in-place-regex.ps1 = ..\build\windows\lib\in-place-regex.ps1 EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Tests.ModApiProvider", "SMAPI.Tests.ModApiProvider\SMAPI.Tests.ModApiProvider.csproj", "{239AEEAC-07D1-4A3F-AA99-8C74F5038F50}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SMAPI.Tests.ModApiConsumer", "SMAPI.Tests.ModApiConsumer\SMAPI.Tests.ModApiConsumer.csproj", "{2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}" +EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution SMAPI.Internal\SMAPI.Internal.projitems*{0634ea4c-3b8f-42db-aea6-ca9e4ef6e92f}*SharedItemsImports = 5 @@ -167,6 +171,14 @@ Global {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Debug|Any CPU.Build.0 = Debug|Any CPU {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.ActiveCfg = Release|Any CPU {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.Build.0 = Release|Any CPU + {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Release|Any CPU.Build.0 = Release|Any CPU + {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -187,6 +199,8 @@ Global {4D661178-38FB-43E4-AA5F-9B0406919344} = {09CF91E5-5BAB-4650-A200-E5EA9A633046} {CAA1488E-842B-433D-994D-1D3D0B5DD125} = {09CF91E5-5BAB-4650-A200-E5EA9A633046} {3B5BF14D-F612-4C83-9EF6-E3EBFCD08766} = {4D661178-38FB-43E4-AA5F-9B0406919344} + {239AEEAC-07D1-4A3F-AA99-8C74F5038F50} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} + {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {70143042-A862-47A8-A677-7C819DDC90DC} -- cgit From 4da9e954df3846d01aa0536f4e8143466a1d62f3 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 11 Feb 2022 00:49:49 -0500 Subject: use Array.Empty to avoid unneeded array allocations --- src/SMAPI.Internal/ExceptionHelper.cs | 2 +- src/SMAPI.Mods.ErrorHandler/ModEntry.cs | 2 +- src/SMAPI.Tests/Core/ModResolverTests.cs | 16 ++++++++-------- .../Framework/Clients/WebApi/ModEntryModel.cs | 4 +++- .../Framework/Clients/WebApi/ModExtendedMetadataModel.cs | 3 ++- .../Framework/Clients/WebApi/ModSearchEntryModel.cs | 4 +++- .../Framework/Clients/Wiki/ChangeDescriptor.cs | 2 +- src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs | 2 +- .../Framework/Clients/Wiki/WikiDataOverrideEntry.cs | 4 +++- src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs | 2 +- src/SMAPI.Toolkit/Serialization/Models/Manifest.cs | 7 ++++--- src/SMAPI.Web/Controllers/IndexController.cs | 2 +- src/SMAPI.Web/Controllers/ModsApiController.cs | 2 +- .../Framework/Caching/Wiki/WikiCacheMemoryRepository.cs | 2 +- src/SMAPI.Web/Framework/Clients/GenericModPage.cs | 3 ++- src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs | 2 +- .../ViewModels/JsonValidator/JsonValidatorModel.cs | 2 +- src/SMAPI.Web/Views/LogParser/Index.cshtml | 2 +- src/SMAPI.Web/Views/Mods/Index.cshtml | 2 +- src/SMAPI/Events/ButtonsChangedEventArgs.cs | 2 +- src/SMAPI/Framework/Events/ManagedEvent.cs | 2 +- src/SMAPI/Framework/Models/SConfig.cs | 4 ++-- src/SMAPI/Framework/SCore.cs | 6 +++--- .../FieldWatchers/ImmutableCollectionWatcher.cs | 5 +++-- src/SMAPI/Framework/StateTracking/LocationTracker.cs | 3 ++- .../Framework/StateTracking/Snapshots/PlayerSnapshot.cs | 2 +- src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs | 2 +- src/SMAPI/Metadata/CoreAssetPropagator.cs | 2 +- src/SMAPI/Utilities/Keybind.cs | 4 ++-- src/SMAPI/Utilities/KeybindList.cs | 4 ++-- 30 files changed, 56 insertions(+), 45 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Internal/ExceptionHelper.cs b/src/SMAPI.Internal/ExceptionHelper.cs index 05b96c2e..03d48911 100644 --- a/src/SMAPI.Internal/ExceptionHelper.cs +++ b/src/SMAPI.Internal/ExceptionHelper.cs @@ -25,7 +25,7 @@ namespace StardewModdingAPI.Internal case ReflectionTypeLoadException ex: string summary = ex.ToString(); - foreach (Exception childEx in ex.LoaderExceptions ?? new Exception[0]) + foreach (Exception childEx in ex.LoaderExceptions ?? Array.Empty()) summary += $"\n\n{childEx?.GetLogSummary()}"; message = summary; break; diff --git a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs index 7286e316..2d6242cf 100644 --- a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs +++ b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs @@ -80,7 +80,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler MethodInfo getMonitorForGame = coreType.GetMethod("GetMonitorForGame") ?? throw new InvalidOperationException("Can't access the SMAPI's 'GetMonitorForGame' method. This mod may not work correctly."); - return (IMonitor)getMonitorForGame.Invoke(core, new object[0]) ?? this.Monitor; + return (IMonitor)getMonitorForGame.Invoke(core, Array.Empty()) ?? this.Monitor; } } } diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs index da3446bb..1755f644 100644 --- a/src/SMAPI.Tests/Core/ModResolverTests.cs +++ b/src/SMAPI.Tests/Core/ModResolverTests.cs @@ -123,7 +123,7 @@ namespace SMAPI.Tests.Core [Test(Description = "Assert that validation doesn't fail if there are no mods installed.")] public void ValidateManifests_NoMods_DoesNothing() { - new ModResolver().ValidateManifests(new ModMetadata[0], apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); + new ModResolver().ValidateManifests(Array.Empty(), apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); } [Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")] @@ -144,7 +144,7 @@ namespace SMAPI.Tests.Core public void ValidateManifests_ModStatus_AssumeBroken_Fails() { // arrange - Mock mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true); + Mock mock = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); this.SetupMetadataForValidation(mock, new ModDataRecordVersionedFields { Status = ModStatus.AssumeBroken @@ -161,7 +161,7 @@ namespace SMAPI.Tests.Core public void ValidateManifests_MinimumApiVersion_Fails() { // arrange - Mock mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true); + Mock mock = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumApiVersion: "1.1")); this.SetupMetadataForValidation(mock); @@ -190,9 +190,9 @@ namespace SMAPI.Tests.Core public void ValidateManifests_DuplicateUniqueID_Fails() { // arrange - Mock modA = this.GetMetadata("Mod A", new string[0], allowStatusChange: true); + Mock modA = this.GetMetadata("Mod A", Array.Empty(), allowStatusChange: true); Mock modB = this.GetMetadata(this.GetManifest(id: "Mod A", name: "Mod B", version: "1.0"), allowStatusChange: true); - Mock modC = this.GetMetadata("Mod C", new string[0], allowStatusChange: false); + Mock modC = this.GetMetadata("Mod C", Array.Empty(), allowStatusChange: false); foreach (Mock mod in new[] { modA, modB, modC }) this.SetupMetadataForValidation(mod); @@ -236,7 +236,7 @@ namespace SMAPI.Tests.Core public void ProcessDependencies_NoMods_DoesNothing() { // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new IModMetadata[0], new ModDatabase()).ToArray(); + IModMetadata[] mods = new ModResolver().ProcessDependencies(Array.Empty(), new ModDatabase()).ToArray(); // assert Assert.AreEqual(0, mods.Length, 0, "Expected to get an empty list of mods."); @@ -490,8 +490,8 @@ namespace SMAPI.Tests.Core EntryDll = entryDll ?? $"{Sample.String()}.dll", ContentPackFor = contentPackForID != null ? new ManifestContentPackFor { UniqueID = contentPackForID } : null, MinimumApiVersion = minimumApiVersion != null ? new SemanticVersion(minimumApiVersion) : null, - Dependencies = dependencies ?? new IManifestDependency[0], - UpdateKeys = new string[0] + Dependencies = dependencies ?? Array.Empty(), + UpdateKeys = Array.Empty() }; } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs index 2f58a3f1..0115fbf3 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs @@ -1,3 +1,5 @@ +using System; + namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi { /// Metadata about a mod. @@ -16,6 +18,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public ModExtendedMetadataModel Metadata { get; set; } /// The errors that occurred while fetching update data. - public string[] Errors { get; set; } = new string[0]; + public string[] Errors { get; set; } = Array.Empty(); } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs index 5c2ce366..0fa4a74d 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; @@ -17,7 +18,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi ** Mod info ****/ /// The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates). - public string[] ID { get; set; } = new string[0]; + public string[] ID { get; set; } = Array.Empty(); /// The mod's display name. public string Name { get; set; } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs index bf81e102..404d4618 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs @@ -1,3 +1,5 @@ +using System; + namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi { /// Specifies the identifiers for a mod to match. @@ -37,7 +39,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi { this.ID = id; this.InstalledVersion = installedVersion; - this.UpdateKeys = updateKeys ?? new string[0]; + this.UpdateKeys = updateKeys ?? Array.Empty(); } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs index f1feb44b..2ed255c8 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs @@ -179,7 +179,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki errors = rawErrors.ToArray(); } else - errors = new string[0]; + errors = Array.Empty(); // build model return new ChangeDescriptor( diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs index f85e82e1..0f5a0ec3 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -239,7 +239,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki string raw = this.GetAttribute(element, name); return !string.IsNullOrWhiteSpace(raw) ? raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() - : new string[0]; + : Array.Empty(); } /// Get an attribute value and parse it as an enum value. diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs index 0587e09d..03c0d214 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs @@ -1,3 +1,5 @@ +using System; + #nullable enable namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki @@ -9,7 +11,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki ** Accessors *********/ /// The unique mod IDs for the mods to override. - public string[] Ids { get; set; } = new string[0]; + public string[] Ids { get; set; } = Array.Empty(); /// Maps local versions to a semantic version for update checks. public ChangeDescriptor? ChangeLocalVersions { get; set; } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs index a9da884a..5b7e2a02 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs @@ -22,7 +22,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData *********/ /// Construct an empty instance. public ModDatabase() - : this(new ModDataRecord[0], key => null) { } + : this(Array.Empty(), key => null) { } /// Construct an instance. /// The underlying mod data records indexed by default display name. diff --git a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs index 46b654a5..4ad97b6d 100644 --- a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Runtime.Serialization; using Newtonsoft.Json; @@ -68,7 +69,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models this.Description = description; this.Version = version; this.UniqueID = uniqueID; - this.UpdateKeys = new string[0]; + this.UpdateKeys = Array.Empty(); this.ContentPackFor = new ManifestContentPackFor { UniqueID = contentPackFor }; } @@ -77,8 +78,8 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models [OnDeserialized] public void OnDeserialized(StreamingContext context) { - this.Dependencies ??= new IManifestDependency[0]; - this.UpdateKeys ??= new string[0]; + this.Dependencies ??= Array.Empty(); + this.UpdateKeys ??= Array.Empty(); } } } diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs index f2f4c342..5097997c 100644 --- a/src/SMAPI.Web/Controllers/IndexController.cs +++ b/src/SMAPI.Web/Controllers/IndexController.cs @@ -96,7 +96,7 @@ namespace StardewModdingAPI.Web.Controllers { HtmlDocument doc = new HtmlDocument(); doc.LoadHtml(release.Body); - foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? new HtmlNode[0]) + foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? Array.Empty()) node.Remove(); release.Body = doc.DocumentNode.InnerHtml.Trim(); } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 37d763cc..dfe2504b 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -79,7 +79,7 @@ namespace StardewModdingAPI.Web.Controllers public async Task> PostAsync([FromBody] ModSearchModel model, [FromRoute] string version) { if (model?.Mods == null) - return new ModEntryModel[0]; + return Array.Empty(); ModUpdateCheckConfig config = this.Config.Value; diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs index 064a7c3c..d037a123 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs @@ -15,7 +15,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki private Cached Metadata; /// The cached wiki data. - private Cached[] Mods = new Cached[0]; + private Cached[] Mods = Array.Empty>(); /********* diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs index 622e6c56..a5f7c9b9 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; @@ -26,7 +27,7 @@ namespace StardewModdingAPI.Web.Framework.Clients public string Url { get; set; } /// The mod downloads. - public IModDownload[] Downloads { get; set; } = new IModDownload[0]; + public IModDownload[] Downloads { get; set; } = Array.Empty(); /// The mod availability status on the remote site. public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs index 87b20eb0..693a16ec 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs @@ -42,7 +42,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models public DateTimeOffset Timestamp { get; set; } /// Metadata about installed mods and content packs. - public LogModInfo[] Mods { get; set; } = new LogModInfo[0]; + public LogModInfo[] Mods { get; set; } = Array.Empty(); /// The log messages. public LogMessage[] Messages { get; set; } diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs index 0ea69911..e659b389 100644 --- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs @@ -26,7 +26,7 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator public string Content { get; set; } /// The schema validation errors, if any. - public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0]; + public JsonValidatorErrorModel[] Errors { get; set; } = Array.Empty(); /// A non-blocking warning while uploading the file. public string UploadWarning { get; set; } diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 91fc3535..993e7244 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -15,7 +15,7 @@ string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true); - ISet screenIds = new HashSet(Model.ParsedLog?.Messages?.Select(p => p.ScreenId) ?? new int[0]); + ISet screenIds = new HashSet(Model.ParsedLog?.Messages?.Select(p => p.ScreenId) ?? Array.Empty()); } @section Head { diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml index 8a764803..416468e4 100644 --- a/src/SMAPI.Web/Views/Mods/Index.cshtml +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -19,7 +19,7 @@ + + + + } - + @@ -275,29 +292,35 @@ else if (log?.IsValid == true) click any mod to filter show all hide all + toggle content packs } @foreach (var mod in log.Mods.Where(p => p.Loaded && !p.IsContentPack)) { + LogModInfo[]? contentPackList; + if (contentPacks == null || !contentPacks.TryGetValue(mod.Name, out contentPackList)) + contentPackList = null; + - - @mod.Name @mod.Version - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList)) + + @mod.Name @mod.Version + @if (contentPackList != null) { -
+
@foreach (var contentPack in contentPackList) { + @contentPack.Name @contentPack.Version
}
+ (+ @contentPackList.Length Content Packs) } - + @mod.Author - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out contentPackList)) + @if (contentPackList != null) { -
+
@foreach (var contentPack in contentPackList) { + @contentPack.Author
@@ -323,57 +346,67 @@ else if (log?.IsValid == true) @if (!Model.ShowRaw) { +
- Filter messages: - TRACE | - DEBUG | - INFO | - ALERT | - WARN | - ERROR +
+
+ Filter messages: +
+
+ TRACE | + DEBUG | + INFO | + ALERT | + WARN | + ERROR +
+ + .* + aA + Ab + HL +
+ +
+
+
- - @foreach (var message in log.Messages) - { - string levelStr = message.Level.ToString().ToLower(); - string sectionStartClass = message.IsStartOfSection ? "section-start" : null; - string sectionFilter = message.Section != null && !message.IsStartOfSection ? $"&& sectionsAllow('{message.Section}')" : null; // filter the message by section if applicable + - v-on:click="toggleSection('@message.Section')" } - v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter"> - - @if (screenIds.Count > 1) - { - - } - - - - - if (message.Repeated > 0) - { - - - - - } - } -
@message.Timescreen_@message.ScreenId@message.Level.ToString().ToUpper()@message.Mod - @message.Text - @if (message.IsStartOfSection) - { - - - - - } -
repeats [@message.Repeated] times.
+ + + } else { diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index 8c3acceb..94bc049b 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -148,6 +148,10 @@ table caption { font-style: italic; } +.content-packs--short { + opacity: 0.75; +} + #metadata td:first-child { padding-right: 5px; } @@ -158,8 +162,58 @@ table caption { #filters { margin: 1em 0 0 0; - padding: 0; + padding: 0 0 0.5em; + display: flex; + justify-content: space-between; + width: calc(100vw - 16em); +} + +#filters > div { + align-self: center; +} + +#filters .toggles { + display: flex; +} + +#filters .toggles > div:first-child { font-weight: bold; + padding: 0.2em 1em 0 0; +} + +#filters .filter-text { + margin-top: 0.5em; +} + +#filters .stats { + margin-top: 0.5em; + font-size: 0.75em; +} + +#filters.sticky { + position: fixed; + top: 0; + left: 0em; + background: #fff; + margin: 0; + padding: 0.5em; + width: calc(100% - 1em); +} + +@media (min-width: 1020px) and (max-width: 1199px) { + #filters:not(.sticky) { + width: calc(100vw - 13em); + } +} + +@media (max-width: 1019px) { + #filters:not(.sticky) { + width: calc(100vw - 3em); + } + + #filters { + display: block; + } } #filters span { @@ -173,6 +227,17 @@ table caption { color: #000; border-color: #880000; background-color: #fcc; + + user-select: none; +} + +#filters .filter-text span { + padding: 3px 0.5em; +} + +#filters .whole-word i { + padding: 0 1px; + border: 1px dashed; } #filters span:hover { @@ -188,11 +253,48 @@ table caption { background: #efe; } +#filters .pager { + margin-top: 0.5em; + text-align: right; +} + +#filters .pager div { + margin-top: 0.5em; +} + +#filters .pager div span { + padding: 0 0.5em; + margin: 0 1px; +} + +#filters .pager span { + background-color: #eee; + border-color: #888; +} + +#filters .pager span.active { + font-weight: bold; + border-color: transparent; + background: transparent; + cursor: unset; +} + +#filters .pager span.disabled { + opacity: 0.3; + cursor: unset; +} + +#filters .pager span:not(.disabled):hover { + background-color: #fff; +} + + /********* ** Log *********/ #log .mod-repeat { font-size: 0.85em; + font-style: italic; } #log .trace { @@ -237,6 +339,11 @@ table caption { white-space: pre-wrap; } +#log .log-message-text strong { + background-color: yellow; + font-weight: normal; +} + #log { border-spacing: 0; } diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 90715375..c16b237a 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -2,12 +2,98 @@ var smapi = smapi || {}; var app; +var messages; + + +// Necessary helper method for updating our text filter in a performant way. +// Wouldn't want to update it for every individually typed character. +function debounce(fn, delay) { + var timeoutID = null + return function () { + clearTimeout(timeoutID) + var args = arguments + var that = this + timeoutID = setTimeout(function () { + fn.apply(that, args) + }, delay) + } +} + +// Case insensitive text searching and match word searching is best done in +// regex, so if the user isn't trying to use regex, escape their input. +function escapeRegex(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Use a scroll event to apply a sticky effect to the filters / pagination +// bar. We can't just use "position: sticky" due to how the page is structured +// but this works well enough. +$(function () { + let sticking = false; + + document.addEventListener('scroll', function (event) { + const filters = document.getElementById('filters'); + const holder = document.getElementById('filterHolder'); + if (!filters || !holder) + return; + + const offset = holder.offsetTop; + const should_stick = window.pageYOffset > offset; + if (should_stick === sticking) + return; + + sticking = should_stick; + if (sticking) { + holder.style.marginBottom = `calc(1em + ${filters.offsetHeight}px)`; + filters.classList.add('sticky'); + } else { + filters.classList.remove('sticky'); + holder.style.marginBottom = ''; + } + }); +}); + +// This method is called when we click a log line to toggle the visibility +// of a section. Binding methods is problematic with functional components +// so we just use the `data-section` parameter and our global reference +// to the app. +smapi.clickLogLine = function (event) { + app.toggleSection(event.currentTarget.dataset.section); + event.preventDefault(); + return false; +} + +// And these methods are called when doing pagination. Just makes things +// easier, so may as well use helpers. +smapi.prevPage = function () { + app.prevPage(); +} + +smapi.nextPage = function () { + app.nextPage(); +} + +smapi.changePage = function (event) { + if (typeof event === 'number') + app.changePage(event); + else if (event) { + const page = parseInt(event.currentTarget.dataset.page); + if (!isNaN(page) && isFinite(page)) + app.changePage(page); + } +} + + smapi.logParser = function (data, sectionUrl) { + if (!data) + data = {}; + // internal filter counts var stats = data.stats = { modsShown: 0, modsHidden: 0 }; + function updateModFilters() { // counts stats.modsShown = 0; @@ -22,10 +108,357 @@ smapi.logParser = function (data, sectionUrl) { } } + // load our data + + // Rather than pre-rendering the list elements into the document, we read + // a lot of JSON and use Vue to build the list. This is a lot more + // performant and easier on memory.Our JSON is stored in special script + // tags, that we later remove to let the browser clean up even more memory. + let nodeParsedMessages = document.querySelector('script#parsedMessages'); + if (nodeParsedMessages) { + messages = JSON.parse(nodeParsedMessages.textContent) || []; + const logLevels = JSON.parse(document.querySelector('script#logLevels').textContent) || {}; + const logSections = JSON.parse(document.querySelector('script#logSections').textContent) || {}; + const modSlugs = JSON.parse(document.querySelector('script#modSlugs').textContent) || {}; + + // Remove all references to the script tags and remove them from the + // DOM so that the browser can clean them up. + nodeParsedMessages.remove(); + document.querySelector('script#logLevels').remove(); + document.querySelector('script#logSections').remove(); + document.querySelector('script#modSlugs').remove(); + nodeParsedMessages = null; + + // Pre-process the messages since they aren't quite serialized in + // the format we want. We also want to freeze every last message + // so that Vue won't install its change listening behavior. + for (let i = 0, length = messages.length; i < length; i++) { + const msg = messages[i]; + msg.id = i; + msg.LevelName = logLevels && logLevels[msg.Level]; + msg.SectionName = logSections && logSections[msg.Section]; + msg.ModSlug = modSlugs && modSlugs[msg.Mod] || msg.Mod; + + // For repeated messages, since our component + // can't return two rows, just insert a second message + // which will display as the message repeated notice. + if (msg.Repeated > 0 && ! msg.isRepeated) { + const second = { + id: i + 1, + Level: msg.Level, + Section: msg.Section, + Mod: msg.Mod, + Repeated: msg.Repeated, + isRepeated: true, + }; + + messages.splice(i + 1, 0, second); + length++; + } + + Object.freeze(msg); + } + + Object.freeze(messages); + + } else + messages = []; + // set local time started - if (data) + if (data.logStarted) data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2); + // Add some properties to the data we're passing to Vue. + data.totalMessages = messages.length; + + data.filterText = ''; + data.filterRegex = ''; + + data.showContentPacks = true; + data.useHighlight = true; + data.useRegex = false; + data.useInsensitive = true; + data.useWord = false; + + data.perPage = 1000; + data.page = 1; + + // Now load these values. + if (localStorage.settings) { + try { + const saved = JSON.parse(localStorage.settings); + if (saved.hasOwnProperty('showContentPacks')) + data.showContentPacks = saved.showContentPacks; + if (saved.hasOwnProperty('useHighlight')) + dat.useHighlight = saved.useHighlight; + if (saved.hasOwnProperty('useRegex')) + data.useRegex = saved.useRegex; + if (saved.hasOwnProperty('useInsensitive')) + data.useInsensitive = saved.useInsensitive; + if (saved.hasOwnProperty('useWord')) + data.useWord = saved.useWord; + } catch { /* ignore errors */ } + } + + // This would be easier if we could just use JSX but this project doesn't + // have a proper JavaScript build environment and I really don't feel + // like setting one up. + + // Add a number formatter so that our numbers look nicer. + const fmt = window.Intl && Intl.NumberFormat && new Intl.NumberFormat(); + function formatNumber(value) { + if (!fmt || !fmt.format) return `${value}`; + return fmt.format(value); + } + Vue.filter('number', formatNumber); + + // Strictly speaking, we don't need this. However, due to the way our + // Vue template is living in-page the browser is "helpful" and moves + // our s outside of a basic since obviously they + // aren't table rows and don't belong inside a table. By using another + // Vue component, we avoid that. + Vue.component('log-table', { + functional: true, + render: function (createElement, context) { + return createElement('table', { + attrs: { + id: 'log' + } + }, context.children); + } + }); + + // The component draws a nice message under the filters + // telling a user how many messages match their filters, and also expands + // on how many of them they're seeing because of pagination. + Vue.component('filter-stats', { + functional: true, + render: function (createElement, context) { + const props = context.props; + if (props.pages > 1) + return createElement('div', { + class: 'stats' + }, [ + 'showing ', + createElement('strong', formatNumber(props.start + 1)), + ' to ', + createElement('strong', formatNumber(props.end)), + ' of ', + createElement('strong', formatNumber(props.filtered)), + ' (total: ', + createElement('strong', formatNumber(props.total)), + ')' + ]); + + return createElement('div', { + class: 'stats' + }, [ + 'showing ', + createElement('strong', formatNumber(props.filtered)), + ' out of ', + createElement('strong', formatNumber(props.total)) + ]); + } + }); + + // Next up we have which renders the pagination list. This has a + // helper method to make building the list of links easier. + function addPageLink(page, links, visited, createElement, currentPage) { + if (visited.has(page)) + return; + + if (page > 1 && !visited.has(page - 1)) + links.push(' … '); + + visited.add(page); + links.push(createElement('span', { + class: page == currentPage ? 'active' : null, + attrs: { + 'data-page': page + }, + on: { + click: smapi.changePage + } + }, formatNumber(page))); + } + + Vue.component('pager', { + functional: true, + render: function (createElement, context) { + const props = context.props; + if (props.pages <= 1) + return null; + + const visited = new Set; + const pageLinks = []; + + for (let i = 1; i <= 2; i++) + addPageLink(i, pageLinks, visited, createElement, props.page); + + for (let i = props.page - 2; i <= props.page + 2; i++) { + if (i < 1 || i > props.pages) + continue; + + addPageLink(i, pageLinks, visited, createElement, props.page); + } + + for (let i = props.pages - 2; i <= props.pages; i++) { + if (i < 1) + continue; + + addPageLink(i, pageLinks, visited, createElement, props.page); + } + + return createElement('div', { + class: 'pager' + }, [ + createElement('span', { + class: props.page <= 1 ? 'disabled' : null, + on: { + click: smapi.prevPage + } + }, 'Prev'), + ' ', + 'Page ', + formatNumber(props.page), + ' of ', + formatNumber(props.pages), + ' ', + createElement('span', { + class: props.page >= props.pages ? 'disabled' : null, + on: { + click: smapi.nextPage + } + }, 'Next'), + createElement('div', {}, pageLinks) + ]); + } + }); + + // Our functional component draws each log line. + Vue.component('log-line', { + functional: true, + props: { + showScreenId: { + type: Boolean, + required: true + }, + message: { + type: Object, + required: true + }, + sectionExpanded: { + type: Boolean, + required: false + }, + highlight: { + type: Boolean, + required: false + } + }, + render: function (createElement, context) { + const msg = context.props.message; + const level = msg.LevelName; + + if (msg.isRepeated) + return createElement('tr', { + class: [ + "mod", + level, + "mod-repeat" + ] + }, [ + createElement('td', { + attrs: { + colspan: context.props.showScreenId ? 4 : 3 + } + }, ''), + createElement('td', `repeats ${msg.Repeated} times`) + ]); + + const events = {}; + let toggleMessage; + if (msg.IsStartOfSection) { + const visible = context.props.sectionExpanded; + events.click = smapi.clickLogLine; + toggleMessage = visible ? + 'This section is shown. Click here to hide it.' : + 'This section is hidden. Click here to show it.'; + } + + let text = msg.Text; + const filter = window.app && app.filterRegex; + if (text && filter && context.props.highlight) { + text = []; + let match, consumed = 0, idx = 0; + filter.lastIndex = -1; + + // Our logic to highlight the text is a bit funky because we + // want to group consecutive matches to avoid a situation + // where a ton of single characters are in their own elements + // if the user gives us bad input. + + while (match = filter.exec(msg.Text)) { + // Do we have an area of non-matching text? This + // happens if the new match's index is further + // along than the last index. + if (match.index > idx) { + // Alright, do we have a previous match? If + // we do, we need to consume some text. + if (consumed < idx) + text.push(createElement('strong', {}, msg.Text.slice(consumed, idx))); + + text.push(msg.Text.slice(idx, match.index)); + consumed = match.index; + } + + idx = match.index + match[0].length; + } + + // Add any trailing text after the last match was found. + if (consumed < msg.Text.length) { + if (consumed < idx) + text.push(createElement('strong', {}, msg.Text.slice(consumed, idx))); + + if (idx < msg.Text.length) + text.push(msg.Text.slice(idx)); + } + } + + return createElement('tr', { + class: [ + "mod", + level, + msg.IsStartOfSection ? "section-start" : null + ], + attrs: { + 'data-section': msg.SectionName + }, + on: events + }, [ + createElement('td', msg.Time), + context.props.showScreenId ? createElement('td', msg.ScreenId) : null, + createElement('td', level.toUpperCase()), + createElement('td', { + attrs: { + 'data-title': msg.Mod + } + }, msg.Mod), + createElement('td', [ + createElement('span', { + class: 'log-message-text' + }, text), + msg.IsStartOfSection ? createElement('span', { + class: 'section-toggle-message' + }, [ + ' ', + toggleMessage + ]) : null + ]) + ]); + } + }); + // init app app = new Vue({ el: '#output', @@ -36,9 +469,114 @@ smapi.logParser = function (data, sectionUrl) { }, anyModsShown: function () { return stats.modsShown > 0; + }, + showScreenId: function () { + return this.screenIds.length > 1; + }, + + // Maybe not strictly necessary, but the Vue template is being + // weird about accessing data entries on the app rather than + // computed properties. + visibleSections: function () { + const ret = []; + for (const [k, v] of Object.entries(this.showSections)) + if (v !== false) + ret.push(k); + return ret; + }, + hideContentPacks: function () { + return !data.showContentPacks; + }, + + // Filter messages for visibility. + filterUseRegex: function () { return data.useRegex; }, + filterInsensitive: function () { return data.useInsensitive; }, + filterUseWord: function () { return data.useWord; }, + shouldHighlight: function () { return data.useHighlight; }, + + filteredMessages: function () { + if (!messages) + return []; + + const start = performance.now(); + const ret = []; + + // This is slightly faster than messages.filter(), which is + // important when working with absolutely huge logs. + for (let i = 0, length = messages.length; i < length; i++) { + const msg = messages[i]; + if (msg.SectionName && !msg.IsStartOfSection && !this.sectionsAllow(msg.SectionName)) + continue; + + if (!this.filtersAllow(msg.ModSlug, msg.LevelName)) + continue; + + let text = msg.Text || (i > 0 ? messages[i - 1].Text : null); + + if (this.filterRegex) { + this.filterRegex.lastIndex = -1; + if (!text || !this.filterRegex.test(text)) + continue; + } else if (this.filterText && (!text || text.indexOf(this.filterText) == -1)) + continue; + + ret.push(msg); + } + + const end = performance.now(); + console.log(`filter took ${end - start}ms`); + + return ret; + }, + + // And the rest are about pagination. + start: function () { + return (this.page - 1) * data.perPage; + }, + end: function () { + return this.start + this.visibleMessages.length; + }, + totalPages: function () { + return Math.ceil(this.filteredMessages.length / data.perPage); + }, + // + visibleMessages: function () { + if (this.totalPages <= 1) + return this.filteredMessages; + + const start = this.start; + const end = start + data.perPage; + + return this.filteredMessages.slice(start, end); } }, + created: function () { + this.loadFromUrl = this.loadFromUrl.bind(this); + window.addEventListener('popstate', this.loadFromUrl); + this.loadFromUrl(); + }, methods: { + // Mostly I wanted people to know they can override the PerPage + // message count with a URL parameter, but we can read Page too. + // In the future maybe we should read *all* filter state so a + // user can link to their exact page state for someone else? + loadFromUrl: function () { + const params = new URL(location).searchParams; + if (params.has('PerPage')) + try { + const perPage = parseInt(params.get('PerPage')); + if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) + data.perPage = perPage; + } catch { /* ignore errors */ } + + if (params.has('Page')) + try { + const page = parseInt(params.get('Page')); + if (!isNaN(page) && isFinite(page) && page > 0) + this.page = page; + } catch { /* ignore errors */ } + }, + toggleLevel: function (id) { if (!data.enableFilters) return; @@ -46,6 +584,98 @@ smapi.logParser = function (data, sectionUrl) { this.showLevels[id] = !this.showLevels[id]; }, + toggleContentPacks: function () { + data.showContentPacks = !data.showContentPacks; + this.saveSettings(); + }, + + toggleFilterUseRegex: function () { + data.useRegex = !data.useRegex; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleFilterInsensitive: function () { + data.useInsensitive = !data.useInsensitive; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleFilterWord: function () { + data.useWord = !data.useWord; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleHighlight: function () { + data.useHighlight = !data.useHighlight; + this.saveSettings(); + }, + + prevPage: function () { + if (this.page <= 1) + return; + this.page--; + this.updateUrl(); + }, + + nextPage: function () { + if (this.page >= this.totalPages) + return; + this.page++; + this.updateUrl(); + }, + + changePage: function (page) { + if (page < 1 || page > this.totalPages) + return; + this.page = page; + this.updateUrl(); + }, + + // Persist settings into localStorage for use the next time + // the user opens a log. + saveSettings: function () { + localStorage.settings = JSON.stringify({ + showContentPacks: data.showContentPacks, + useRegex: data.useRegex, + useInsensitive: data.useInsensitive, + useWord: data.useWord, + useHighlight: data.useHighlight + }); + }, + + // Whenever the page is changed, replace the current page URL. Using + // replaceState rather than pushState to avoid filling the tab history + // with tons of useless history steps the user probably doesn't + // really care about. + updateUrl: function () { + const url = new URL(location); + url.searchParams.set('Page', data.page); + url.searchParams.set('PerPage', data.perPage); + + window.history.replaceState(null, document.title, url.toString()); + }, + + // We don't want to update the filter text often, so use a debounce with + // a quarter second delay. We basically always build a regular expression + // since we use it for highlighting, and it also make case insensitivity + // much easier. + updateFilterText: debounce(function () { + let text = this.filterText = document.querySelector('input[type=text]').value; + if (!text || !text.length) { + this.filterText = ''; + this.filterRegex = null; + } else { + if (!data.useRegex) + text = escapeRegex(text); + this.filterRegex = new RegExp( + data.useWord ? `\\b${text}\\b` : text, + data.useInsensitive ? 'ig' : 'g' + ); + } + }, 250), + toggleMod: function (id) { if (!data.enableFilters) return; -- cgit From 631d0375c3868cb68d1487662955db4ea1b7dd77 Mon Sep 17 00:00:00 2001 From: Khloe Leclair Date: Fri, 8 Apr 2022 15:26:35 -0400 Subject: Simplify visible section checking by abusing Vue behavior, since the proper way is being buggy. --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 1 - src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 13 +------------ 2 files changed, 1 insertion(+), 13 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 39a2da0f..8f44b4a2 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -404,7 +404,6 @@ else if (log?.IsValid == true) v-bind:showScreenId="showScreenId" v-bind:message="msg" v-bind:highlight="shouldHighlight" - v-bind:sectionExpanded="msg.SectionName && visibleSections.includes(msg.SectionName)" /> } diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index c16b237a..1984d58f 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -347,10 +347,6 @@ smapi.logParser = function (data, sectionUrl) { type: Object, required: true }, - sectionExpanded: { - type: Boolean, - required: false - }, highlight: { type: Boolean, required: false @@ -379,7 +375,7 @@ smapi.logParser = function (data, sectionUrl) { const events = {}; let toggleMessage; if (msg.IsStartOfSection) { - const visible = context.props.sectionExpanded; + const visible = msg.SectionName && window.app && app.sectionsAllow(msg.SectionName); events.click = smapi.clickLogLine; toggleMessage = visible ? 'This section is shown. Click here to hide it.' : @@ -477,13 +473,6 @@ smapi.logParser = function (data, sectionUrl) { // Maybe not strictly necessary, but the Vue template is being // weird about accessing data entries on the app rather than // computed properties. - visibleSections: function () { - const ret = []; - for (const [k, v] of Object.entries(this.showSections)) - if (v !== false) - ret.push(k); - return ret; - }, hideContentPacks: function () { return !data.showContentPacks; }, -- cgit From 5ae87fbc01a8829a1a23f90efc25a5dbaada6e68 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 11:42:49 -0400 Subject: fix deprecation warning when a mod uses LoadFromModFile --- src/SMAPI/Events/AssetRequestedEventArgs.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/SMAPI/Events/AssetRequestedEventArgs.cs b/src/SMAPI/Events/AssetRequestedEventArgs.cs index 82b59290..3c51c95d 100644 --- a/src/SMAPI/Events/AssetRequestedEventArgs.cs +++ b/src/SMAPI/Events/AssetRequestedEventArgs.cs @@ -101,7 +101,7 @@ namespace StardewModdingAPI.Events mod: this.Mod, priority: priority, onBehalfOf: null, - _ => this.Mod.Mod.Helper.Content.Load(relativePath)) + _ => this.Mod.Mod.Helper.ModContent.Load(relativePath)) ); } -- cgit From 092f0aa4eaa23c169c1ca5e8b213915f563f5053 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 11:52:20 -0400 Subject: simplify format for new CLI arguments --- src/SMAPI/Framework/SCore.cs | 11 +++-------- src/SMAPI/Program.cs | 22 +++++++--------------- 2 files changed, 10 insertions(+), 23 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 801a7237..4746c2ce 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -168,8 +168,8 @@ namespace StardewModdingAPI.Framework /// Construct an instance. /// The path to search for mods. /// Whether to output log messages to the console. - /// null if not modified else whether to use developer mode - public SCore(string modsPath, bool writeToConsole, bool? developerModeValue) + /// Whether to enable development features, or null to use the value from the settings file. + public SCore(string modsPath, bool writeToConsole, bool? developerMode) { SCore.Instance = this; @@ -184,12 +184,7 @@ namespace StardewModdingAPI.Framework // init basics this.Settings = JsonConvert.DeserializeObject(File.ReadAllText(Constants.ApiConfigPath)); - - // temporary overwrite DeveloperMode Setting - if (developerModeValue.HasValue) - { - this.Settings.DeveloperMode = developerModeValue.Value; - } + this.Settings.DeveloperMode = developerMode ?? this.Settings.DeveloperMode; if (File.Exists(Constants.ApiUserConfigPath)) JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings); diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 32bf0bdd..1e3b2000 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -179,7 +179,7 @@ namespace StardewModdingAPI bool writeToConsole = !args.Contains("--no-terminal") && Environment.GetEnvironmentVariable("SMAPI_NO_TERMINAL") == null; // get mods path - bool? developerModeValue = null; + bool? developerMode = null; string modsPath; { string rawModsPath = null; @@ -190,31 +190,23 @@ namespace StardewModdingAPI rawModsPath = args[pathIndex]; // get developer mode from command line args - int developerModeValueIndex = Array.LastIndexOf(args, "--developer-mode") + 1; - if (developerModeValueIndex >= 1 && args.Length >= developerModeValueIndex) - { - if (args[developerModeValueIndex].ToLower().Equals("true")) - { - developerModeValue = true; - } - else if (args[developerModeValueIndex].ToLower().Equals("false")) - { - developerModeValue = false; - } - } + if (args.Contains("--developer-mode")) + developerMode = true; + if (args.Contains("--developer-mode-off")) + developerMode = false; // get from environment variables if (string.IsNullOrWhiteSpace(rawModsPath)) rawModsPath = Environment.GetEnvironmentVariable("SMAPI_MODS_PATH"); - // normalise + // normalize modsPath = !string.IsNullOrWhiteSpace(rawModsPath) ? Path.Combine(Constants.GamePath, rawModsPath) : Constants.DefaultModsPath; } // load SMAPI - using SCore core = new SCore(modsPath, writeToConsole, developerModeValue); + using SCore core = new SCore(modsPath, writeToConsole, developerMode); core.RunInteractively(); } -- cgit From 6161cc91297dd215a35bfd4d8862d27a0d937898 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 11:54:23 -0400 Subject: fix config.user.json overriding new CLI arguments --- src/SMAPI/Framework/SCore.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 4746c2ce..44f46179 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -184,10 +184,9 @@ namespace StardewModdingAPI.Framework // init basics this.Settings = JsonConvert.DeserializeObject(File.ReadAllText(Constants.ApiConfigPath)); - this.Settings.DeveloperMode = developerMode ?? this.Settings.DeveloperMode; - if (File.Exists(Constants.ApiUserConfigPath)) JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings); + this.Settings.DeveloperMode = developerMode ?? this.Settings.DeveloperMode; this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, isVerbose: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode, getScreenIdForLog: this.GetScreenIdForLog); this.CommandManager = new CommandManager(this.Monitor); -- cgit From 288ef5dc0715339a3a0bf89975a6db7ab7408e2b Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 12:03:30 -0400 Subject: add environment variable form of new CLI args, update docs --- docs/release-notes.md | 2 +- docs/technical/smapi.md | 4 +++- src/SMAPI/Program.cs | 6 ++++++ 3 files changed, 10 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/docs/release-notes.md b/docs/release-notes.md index bb30f31a..9fc0d432 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -20,7 +20,6 @@ * Added `data-*` attributes to the log parser page for external tools. * Fixed JSON validator showing incorrect error for update keys without a subkey. - ### For mod authors This is a big release that includes the new APIs planned for SMAPI 4.0.0, alongside the old ones. @@ -40,6 +39,7 @@ the C# mod that loads them is updated. _This adds support for many previously unsupported cases: proxied interfaces in return values or input arguments, proxied enums if their values match, generic methods, and more. Existing mod APIs should work fine as-is._ * Mod files loaded through SMAPI APIs (including `helper.Content.Load`) are now case-insensitive, even on Linux. * Other improvements: + * Added [command-line arguments](technical/smapi.md#command-line-arguments) to toggle developer mode (thanks to Tondorian!). * Added `IContentPack.ModContent` property. * Added `Constants.ContentPath`. * Added `IAssetName` fields to the info received by `IAssetEditor` and `IAssetLoader` methods. diff --git a/docs/technical/smapi.md b/docs/technical/smapi.md index 7da1e0f1..e117db2f 100644 --- a/docs/technical/smapi.md +++ b/docs/technical/smapi.md @@ -33,11 +33,12 @@ argument | purpose `--uninstall` | Preselects the uninstall action, skipping the prompt asking what the user wants to do. `--game-path "path"` | Specifies the full path to the folder containing the Stardew Valley executable, skipping automatic detection and any prompt to choose a path. If the path is not valid, the installer displays an error. -SMAPI itself recognises two arguments **on Windows only**, but these are intended for internal use +SMAPI itself recognises five arguments **on Windows only**, but these are intended for internal use or testing and may change without warning. On Linux/macOS, see _environment variables_ below. argument | purpose -------- | ------- +`--developer-mode`
`--developer-mode-off` | Enable or disable features intended for mod developers. Currently this only makes `TRACE`-level messages appear in the console. `--no-terminal` | The SMAPI launcher won't try to open a terminal window, and SMAPI won't log anything to the console. (Messages will still be written to the log file.) `--use-current-shell` | The SMAPI launcher won't try to open a terminal window, but SMAPI will still log to the console. (Messages will still be written to the log file.) `--mods-path` | The path to search for mods, if not the standard `Mods` folder. This can be a path relative to the game folder (like `--mods-path "Mods (test)"`) or an absolute path. @@ -49,6 +50,7 @@ can set temporary environment variables instead. For example: environment variable | purpose -------------------- | ------- +`SMAPI_DEVELOPER_MODE` | Equivalent to `--developer-mode` and `--developer-mode-off` above. The value must be `true` or `false`. `SMAPI_MODS_PATH` | Equivalent to `--mods-path` above. `SMAPI_NO_TERMINAL` | Equivalent to `--no-terminal` above. `SMAPI_USE_CURRENT_SHELL` | Equivalent to `--use-current-shell` above. diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 0c9c2d87..b2e213fe 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -200,6 +200,12 @@ namespace StardewModdingAPI // get from environment variables if (string.IsNullOrWhiteSpace(rawModsPath)) rawModsPath = Environment.GetEnvironmentVariable("SMAPI_MODS_PATH"); + if (developerMode is null) + { + string rawDeveloperMode = Environment.GetEnvironmentVariable("SMAPI_DEVELOPER_MODE"); + if (rawDeveloperMode != null) + developerMode = bool.Parse(rawDeveloperMode); + } // normalize modsPath = !string.IsNullOrWhiteSpace(rawModsPath) -- cgit From b3519f3cc161f460e56cfc0a0662ec3b5bfb841b Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 12:59:21 -0400 Subject: rename 'data' to 'state' for upcoming changes --- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 106 ++++++++++++------------- 1 file changed, 53 insertions(+), 53 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 1984d58f..51d6b53e 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -84,12 +84,12 @@ smapi.changePage = function (event) { } -smapi.logParser = function (data, sectionUrl) { - if (!data) - data = {}; +smapi.logParser = function (state, sectionUrl) { + if (!state) + state = {}; // internal filter counts - var stats = data.stats = { + var stats = state.stats = { modsShown: 0, modsHidden: 0 }; @@ -98,9 +98,9 @@ smapi.logParser = function (data, sectionUrl) { // counts stats.modsShown = 0; stats.modsHidden = 0; - for (var key in data.showMods) { - if (data.showMods.hasOwnProperty(key)) { - if (data.showMods[key]) + for (var key in state.showMods) { + if (state.showMods.hasOwnProperty(key)) { + if (state.showMods[key]) stats.modsShown++; else stats.modsHidden++; @@ -165,38 +165,38 @@ smapi.logParser = function (data, sectionUrl) { messages = []; // set local time started - if (data.logStarted) - data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2); + if (state.logStarted) + state.localTimeStarted = ("0" + state.logStarted.getHours()).slice(-2) + ":" + ("0" + state.logStarted.getMinutes()).slice(-2); // Add some properties to the data we're passing to Vue. - data.totalMessages = messages.length; + state.totalMessages = messages.length; - data.filterText = ''; - data.filterRegex = ''; + state.filterText = ''; + state.filterRegex = ''; - data.showContentPacks = true; - data.useHighlight = true; - data.useRegex = false; - data.useInsensitive = true; - data.useWord = false; + state.showContentPacks = true; + state.useHighlight = true; + state.useRegex = false; + state.useInsensitive = true; + state.useWord = false; - data.perPage = 1000; - data.page = 1; + state.perPage = 1000; + state.page = 1; // Now load these values. if (localStorage.settings) { try { const saved = JSON.parse(localStorage.settings); if (saved.hasOwnProperty('showContentPacks')) - data.showContentPacks = saved.showContentPacks; + state.showContentPacks = saved.showContentPacks; if (saved.hasOwnProperty('useHighlight')) dat.useHighlight = saved.useHighlight; if (saved.hasOwnProperty('useRegex')) - data.useRegex = saved.useRegex; + state.useRegex = saved.useRegex; if (saved.hasOwnProperty('useInsensitive')) - data.useInsensitive = saved.useInsensitive; + state.useInsensitive = saved.useInsensitive; if (saved.hasOwnProperty('useWord')) - data.useWord = saved.useWord; + state.useWord = saved.useWord; } catch { /* ignore errors */ } } @@ -458,7 +458,7 @@ smapi.logParser = function (data, sectionUrl) { // init app app = new Vue({ el: '#output', - data: data, + data: state, computed: { anyModsHidden: function () { return stats.modsHidden > 0; @@ -474,14 +474,14 @@ smapi.logParser = function (data, sectionUrl) { // weird about accessing data entries on the app rather than // computed properties. hideContentPacks: function () { - return !data.showContentPacks; + return !state.showContentPacks; }, // Filter messages for visibility. - filterUseRegex: function () { return data.useRegex; }, - filterInsensitive: function () { return data.useInsensitive; }, - filterUseWord: function () { return data.useWord; }, - shouldHighlight: function () { return data.useHighlight; }, + filterUseRegex: function () { return state.useRegex; }, + filterInsensitive: function () { return state.useInsensitive; }, + filterUseWord: function () { return state.useWord; }, + shouldHighlight: function () { return state.useHighlight; }, filteredMessages: function () { if (!messages) @@ -520,13 +520,13 @@ smapi.logParser = function (data, sectionUrl) { // And the rest are about pagination. start: function () { - return (this.page - 1) * data.perPage; + return (this.page - 1) * state.perPage; }, end: function () { return this.start + this.visibleMessages.length; }, totalPages: function () { - return Math.ceil(this.filteredMessages.length / data.perPage); + return Math.ceil(this.filteredMessages.length / state.perPage); }, // visibleMessages: function () { @@ -534,7 +534,7 @@ smapi.logParser = function (data, sectionUrl) { return this.filteredMessages; const start = this.start; - const end = start + data.perPage; + const end = start + state.perPage; return this.filteredMessages.slice(start, end); } @@ -555,7 +555,7 @@ smapi.logParser = function (data, sectionUrl) { try { const perPage = parseInt(params.get('PerPage')); if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) - data.perPage = perPage; + state.perPage = perPage; } catch { /* ignore errors */ } if (params.has('Page')) @@ -567,37 +567,37 @@ smapi.logParser = function (data, sectionUrl) { }, toggleLevel: function (id) { - if (!data.enableFilters) + if (!state.enableFilters) return; this.showLevels[id] = !this.showLevels[id]; }, toggleContentPacks: function () { - data.showContentPacks = !data.showContentPacks; + state.showContentPacks = !state.showContentPacks; this.saveSettings(); }, toggleFilterUseRegex: function () { - data.useRegex = !data.useRegex; + state.useRegex = !state.useRegex; this.saveSettings(); this.updateFilterText(); }, toggleFilterInsensitive: function () { - data.useInsensitive = !data.useInsensitive; + state.useInsensitive = !state.useInsensitive; this.saveSettings(); this.updateFilterText(); }, toggleFilterWord: function () { - data.useWord = !data.useWord; + state.useWord = !state.useWord; this.saveSettings(); this.updateFilterText(); }, toggleHighlight: function () { - data.useHighlight = !data.useHighlight; + state.useHighlight = !state.useHighlight; this.saveSettings(); }, @@ -626,11 +626,11 @@ smapi.logParser = function (data, sectionUrl) { // the user opens a log. saveSettings: function () { localStorage.settings = JSON.stringify({ - showContentPacks: data.showContentPacks, - useRegex: data.useRegex, - useInsensitive: data.useInsensitive, - useWord: data.useWord, - useHighlight: data.useHighlight + showContentPacks: state.showContentPacks, + useRegex: state.useRegex, + useInsensitive: state.useInsensitive, + useWord: state.useWord, + useHighlight: state.useHighlight }); }, @@ -640,8 +640,8 @@ smapi.logParser = function (data, sectionUrl) { // really care about. updateUrl: function () { const url = new URL(location); - url.searchParams.set('Page', data.page); - url.searchParams.set('PerPage', data.perPage); + url.searchParams.set('Page', state.page); + url.searchParams.set('PerPage', state.perPage); window.history.replaceState(null, document.title, url.toString()); }, @@ -656,17 +656,17 @@ smapi.logParser = function (data, sectionUrl) { this.filterText = ''; this.filterRegex = null; } else { - if (!data.useRegex) + if (!state.useRegex) text = escapeRegex(text); this.filterRegex = new RegExp( - data.useWord ? `\\b${text}\\b` : text, - data.useInsensitive ? 'ig' : 'g' + state.useWord ? `\\b${text}\\b` : text, + state.useInsensitive ? 'ig' : 'g' ); } }, 250), toggleMod: function (id) { - if (!data.enableFilters) + if (!state.enableFilters) return; var curShown = this.showMods[id]; @@ -689,14 +689,14 @@ smapi.logParser = function (data, sectionUrl) { }, toggleSection: function (name) { - if (!data.enableFilters) + if (!state.enableFilters) return; this.showSections[name] = !this.showSections[name]; }, showAllMods: function () { - if (!data.enableFilters) + if (!state.enableFilters) return; for (var key in this.showMods) { @@ -708,7 +708,7 @@ smapi.logParser = function (data, sectionUrl) { }, hideAllMods: function () { - if (!data.enableFilters) + if (!state.enableFilters) return; for (var key in this.showMods) { -- cgit From 260dbbf205bfa86c76a999ccd00e01941e9ce469 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 13:02:25 -0400 Subject: standardize quote style --- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 192 ++++++++++++------------- 1 file changed, 96 insertions(+), 96 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 51d6b53e..9684834c 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -22,7 +22,7 @@ function debounce(fn, delay) { // Case insensitive text searching and match word searching is best done in // regex, so if the user isn't trying to use regex, escape their input. function escapeRegex(text) { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // Use a scroll event to apply a sticky effect to the filters / pagination @@ -31,9 +31,9 @@ function escapeRegex(text) { $(function () { let sticking = false; - document.addEventListener('scroll', function (event) { - const filters = document.getElementById('filters'); - const holder = document.getElementById('filterHolder'); + document.addEventListener("scroll", function (event) { + const filters = document.getElementById("filters"); + const holder = document.getElementById("filterHolder"); if (!filters || !holder) return; @@ -45,10 +45,10 @@ $(function () { sticking = should_stick; if (sticking) { holder.style.marginBottom = `calc(1em + ${filters.offsetHeight}px)`; - filters.classList.add('sticky'); + filters.classList.add("sticky"); } else { - filters.classList.remove('sticky'); - holder.style.marginBottom = ''; + filters.classList.remove("sticky"); + holder.style.marginBottom = ""; } }); }); @@ -74,7 +74,7 @@ smapi.nextPage = function () { } smapi.changePage = function (event) { - if (typeof event === 'number') + if (typeof event === "number") app.changePage(event); else if (event) { const page = parseInt(event.currentTarget.dataset.page); @@ -114,19 +114,19 @@ smapi.logParser = function (state, sectionUrl) { // a lot of JSON and use Vue to build the list. This is a lot more // performant and easier on memory.Our JSON is stored in special script // tags, that we later remove to let the browser clean up even more memory. - let nodeParsedMessages = document.querySelector('script#parsedMessages'); + let nodeParsedMessages = document.querySelector("script#parsedMessages"); if (nodeParsedMessages) { messages = JSON.parse(nodeParsedMessages.textContent) || []; - const logLevels = JSON.parse(document.querySelector('script#logLevels').textContent) || {}; - const logSections = JSON.parse(document.querySelector('script#logSections').textContent) || {}; - const modSlugs = JSON.parse(document.querySelector('script#modSlugs').textContent) || {}; + const logLevels = JSON.parse(document.querySelector("script#logLevels").textContent) || {}; + const logSections = JSON.parse(document.querySelector("script#logSections").textContent) || {}; + const modSlugs = JSON.parse(document.querySelector("script#modSlugs").textContent) || {}; // Remove all references to the script tags and remove them from the // DOM so that the browser can clean them up. nodeParsedMessages.remove(); - document.querySelector('script#logLevels').remove(); - document.querySelector('script#logSections').remove(); - document.querySelector('script#modSlugs').remove(); + document.querySelector("script#logLevels").remove(); + document.querySelector("script#logSections").remove(); + document.querySelector("script#modSlugs").remove(); nodeParsedMessages = null; // Pre-process the messages since they aren't quite serialized in @@ -171,8 +171,8 @@ smapi.logParser = function (state, sectionUrl) { // Add some properties to the data we're passing to Vue. state.totalMessages = messages.length; - state.filterText = ''; - state.filterRegex = ''; + state.filterText = ""; + state.filterRegex = ""; state.showContentPacks = true; state.useHighlight = true; @@ -187,15 +187,15 @@ smapi.logParser = function (state, sectionUrl) { if (localStorage.settings) { try { const saved = JSON.parse(localStorage.settings); - if (saved.hasOwnProperty('showContentPacks')) + if (saved.hasOwnProperty("showContentPacks")) state.showContentPacks = saved.showContentPacks; - if (saved.hasOwnProperty('useHighlight')) + if (saved.hasOwnProperty("useHighlight")) dat.useHighlight = saved.useHighlight; - if (saved.hasOwnProperty('useRegex')) + if (saved.hasOwnProperty("useRegex")) state.useRegex = saved.useRegex; - if (saved.hasOwnProperty('useInsensitive')) + if (saved.hasOwnProperty("useInsensitive")) state.useInsensitive = saved.useInsensitive; - if (saved.hasOwnProperty('useWord')) + if (saved.hasOwnProperty("useWord")) state.useWord = saved.useWord; } catch { /* ignore errors */ } } @@ -210,19 +210,19 @@ smapi.logParser = function (state, sectionUrl) { if (!fmt || !fmt.format) return `${value}`; return fmt.format(value); } - Vue.filter('number', formatNumber); + Vue.filter("number", formatNumber); // Strictly speaking, we don't need this. However, due to the way our // Vue template is living in-page the browser is "helpful" and moves // our s outside of a basic
since obviously they // aren't table rows and don't belong inside a table. By using another // Vue component, we avoid that. - Vue.component('log-table', { + Vue.component("log-table", { functional: true, render: function (createElement, context) { - return createElement('table', { + return createElement("table", { attrs: { - id: 'log' + id: "log" } }, context.children); } @@ -231,32 +231,32 @@ smapi.logParser = function (state, sectionUrl) { // The component draws a nice message under the filters // telling a user how many messages match their filters, and also expands // on how many of them they're seeing because of pagination. - Vue.component('filter-stats', { + Vue.component("filter-stats", { functional: true, render: function (createElement, context) { const props = context.props; if (props.pages > 1) - return createElement('div', { - class: 'stats' + return createElement("div", { + class: "stats" }, [ - 'showing ', - createElement('strong', formatNumber(props.start + 1)), - ' to ', - createElement('strong', formatNumber(props.end)), - ' of ', - createElement('strong', formatNumber(props.filtered)), - ' (total: ', - createElement('strong', formatNumber(props.total)), - ')' + "showing ", + createElement("strong", formatNumber(props.start + 1)), + " to ", + createElement("strong", formatNumber(props.end)), + " of ", + createElement("strong", formatNumber(props.filtered)), + " (total: ", + createElement("strong", formatNumber(props.total)), + ")" ]); - return createElement('div', { - class: 'stats' + return createElement("div", { + class: "stats" }, [ - 'showing ', - createElement('strong', formatNumber(props.filtered)), - ' out of ', - createElement('strong', formatNumber(props.total)) + "showing ", + createElement("strong", formatNumber(props.filtered)), + " out of ", + createElement("strong", formatNumber(props.total)) ]); } }); @@ -268,13 +268,13 @@ smapi.logParser = function (state, sectionUrl) { return; if (page > 1 && !visited.has(page - 1)) - links.push(' … '); + links.push(" … "); visited.add(page); - links.push(createElement('span', { - class: page == currentPage ? 'active' : null, + links.push(createElement("span", { + class: page == currentPage ? "active" : null, attrs: { - 'data-page': page + "data-page": page }, on: { click: smapi.changePage @@ -282,7 +282,7 @@ smapi.logParser = function (state, sectionUrl) { }, formatNumber(page))); } - Vue.component('pager', { + Vue.component("pager", { functional: true, render: function (createElement, context) { const props = context.props; @@ -309,34 +309,34 @@ smapi.logParser = function (state, sectionUrl) { addPageLink(i, pageLinks, visited, createElement, props.page); } - return createElement('div', { - class: 'pager' + return createElement("div", { + class: "pager" }, [ - createElement('span', { - class: props.page <= 1 ? 'disabled' : null, + createElement("span", { + class: props.page <= 1 ? "disabled" : null, on: { click: smapi.prevPage } - }, 'Prev'), - ' ', - 'Page ', + }, "Prev"), + " ", + "Page ", formatNumber(props.page), - ' of ', + " of ", formatNumber(props.pages), - ' ', - createElement('span', { - class: props.page >= props.pages ? 'disabled' : null, + " ", + createElement("span", { + class: props.page >= props.pages ? "disabled" : null, on: { click: smapi.nextPage } - }, 'Next'), - createElement('div', {}, pageLinks) + }, "Next"), + createElement("div", {}, pageLinks) ]); } }); // Our functional component draws each log line. - Vue.component('log-line', { + Vue.component("log-line", { functional: true, props: { showScreenId: { @@ -357,19 +357,19 @@ smapi.logParser = function (state, sectionUrl) { const level = msg.LevelName; if (msg.isRepeated) - return createElement('tr', { + return createElement("tr", { class: [ "mod", level, "mod-repeat" ] }, [ - createElement('td', { + createElement("td", { attrs: { colspan: context.props.showScreenId ? 4 : 3 } - }, ''), - createElement('td', `repeats ${msg.Repeated} times`) + }, ""), + createElement("td", `repeats ${msg.Repeated} times`) ]); const events = {}; @@ -377,9 +377,9 @@ smapi.logParser = function (state, sectionUrl) { if (msg.IsStartOfSection) { const visible = msg.SectionName && window.app && app.sectionsAllow(msg.SectionName); events.click = smapi.clickLogLine; - toggleMessage = visible ? - 'This section is shown. Click here to hide it.' : - 'This section is hidden. Click here to show it.'; + toggleMessage = visible + ? "This section is shown. Click here to hide it." + : "This section is hidden. Click here to show it."; } let text = msg.Text; @@ -402,7 +402,7 @@ smapi.logParser = function (state, sectionUrl) { // Alright, do we have a previous match? If // we do, we need to consume some text. if (consumed < idx) - text.push(createElement('strong', {}, msg.Text.slice(consumed, idx))); + text.push(createElement("strong", {}, msg.Text.slice(consumed, idx))); text.push(msg.Text.slice(idx, match.index)); consumed = match.index; @@ -414,40 +414,40 @@ smapi.logParser = function (state, sectionUrl) { // Add any trailing text after the last match was found. if (consumed < msg.Text.length) { if (consumed < idx) - text.push(createElement('strong', {}, msg.Text.slice(consumed, idx))); + text.push(createElement("strong", {}, msg.Text.slice(consumed, idx))); if (idx < msg.Text.length) text.push(msg.Text.slice(idx)); } } - return createElement('tr', { + return createElement("tr", { class: [ "mod", level, msg.IsStartOfSection ? "section-start" : null ], attrs: { - 'data-section': msg.SectionName + "data-section": msg.SectionName }, on: events }, [ - createElement('td', msg.Time), - context.props.showScreenId ? createElement('td', msg.ScreenId) : null, - createElement('td', level.toUpperCase()), - createElement('td', { + createElement("td", msg.Time), + context.props.showScreenId ? createElement("td", msg.ScreenId) : null, + createElement("td", level.toUpperCase()), + createElement("td", { attrs: { - 'data-title': msg.Mod + "data-title": msg.Mod } }, msg.Mod), - createElement('td', [ - createElement('span', { - class: 'log-message-text' + createElement("td", [ + createElement("span", { + class: "log-message-text" }, text), - msg.IsStartOfSection ? createElement('span', { - class: 'section-toggle-message' + msg.IsStartOfSection ? createElement("span", { + class: "section-toggle-message" }, [ - ' ', + " ", toggleMessage ]) : null ]) @@ -457,7 +457,7 @@ smapi.logParser = function (state, sectionUrl) { // init app app = new Vue({ - el: '#output', + el: "#output", data: state, computed: { anyModsHidden: function () { @@ -541,7 +541,7 @@ smapi.logParser = function (state, sectionUrl) { }, created: function () { this.loadFromUrl = this.loadFromUrl.bind(this); - window.addEventListener('popstate', this.loadFromUrl); + window.addEventListener("popstate", this.loadFromUrl); this.loadFromUrl(); }, methods: { @@ -551,16 +551,16 @@ smapi.logParser = function (state, sectionUrl) { // user can link to their exact page state for someone else? loadFromUrl: function () { const params = new URL(location).searchParams; - if (params.has('PerPage')) + if (params.has("PerPage")) try { - const perPage = parseInt(params.get('PerPage')); + const perPage = parseInt(params.get("PerPage")); if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) state.perPage = perPage; } catch { /* ignore errors */ } - if (params.has('Page')) + if (params.has("Page")) try { - const page = parseInt(params.get('Page')); + const page = parseInt(params.get("Page")); if (!isNaN(page) && isFinite(page) && page > 0) this.page = page; } catch { /* ignore errors */ } @@ -640,8 +640,8 @@ smapi.logParser = function (state, sectionUrl) { // really care about. updateUrl: function () { const url = new URL(location); - url.searchParams.set('Page', state.page); - url.searchParams.set('PerPage', state.perPage); + url.searchParams.set("Page", state.page); + url.searchParams.set("PerPage", state.perPage); window.history.replaceState(null, document.title, url.toString()); }, @@ -651,16 +651,16 @@ smapi.logParser = function (state, sectionUrl) { // since we use it for highlighting, and it also make case insensitivity // much easier. updateFilterText: debounce(function () { - let text = this.filterText = document.querySelector('input[type=text]').value; + let text = this.filterText = document.querySelector("input[type=text]").value; if (!text || !text.length) { - this.filterText = ''; + this.filterText = ""; this.filterRegex = null; } else { if (!state.useRegex) text = escapeRegex(text); this.filterRegex = new RegExp( state.useWord ? `\\b${text}\\b` : text, - state.useInsensitive ? 'ig' : 'g' + state.useInsensitive ? "ig" : "g" ); } }, 250), -- cgit From ccf760452d64e1965c92c5cf8af399a5e80d5a3a Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 13:08:38 -0400 Subject: pass data directly to script instead of serializing & deserializing it --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 43 ++++++---- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 113 +++++++++++-------------- 2 files changed, 76 insertions(+), 80 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 8f44b4a2..ff8aa003 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -44,13 +44,6 @@ - @if (!Model.ShowRaw) - { - - - - - } @@ -58,15 +51,33 @@ } @@ -217,12 +213,12 @@ else if (log?.IsValid == true) Consider updating these mods to fix problems:
- @foreach (LogModInfo mod in log.Mods.Where(mod => (mod.HasUpdate && !mod.IsContentPack) || (contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList) && contentPackList.Any(pack => pack.HasUpdate)))) + @foreach (LogModInfo mod in log.Mods.Where(mod => (mod.HasUpdate && !mod.IsContentPack) || (contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList) && contentPackList.Any(pack => pack.HasUpdate)))) { -- cgit From 26d29a1070e00b4edeaf3334d4c4d072d52a56ff Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 15:44:17 -0400 Subject: minor refactoring --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 23 +- src/SMAPI.Web/wwwroot/Content/css/log-parser.css | 8 +- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 629 +++++++++++++---------- src/SMAPI.sln.DotSettings | 2 + 4 files changed, 382 insertions(+), 280 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index a7552888..2d5dd403 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -68,8 +68,7 @@ showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, _ => false)), showLevels: @this.ForJson(defaultFilters), enableFilters: @this.ForJson(!Model.ShowRaw) - }, - "@this.Url.PlainAction("Index", "LogParser", values: null)" + } ); new Tabby("[data-tabs]"); @@ -296,7 +295,7 @@ else if (log?.IsValid == true) click any mod to filter show all hide all - toggle content packs + toggle content packs in list } @foreach (var mod in log.Mods.Where(p => p.Loaded && !p.IsContentPack)) @@ -316,7 +315,7 @@ else if (log?.IsValid == true) + @contentPack.Name @contentPack.Version
} - (+ @contentPackList.Length Content Packs) + (+ @contentPackList.Length content packs) }
@mod.Name - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList)) + @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList)) {
@foreach (LogModInfo contentPack in contentPackList.Where(pack => pack.HasUpdate)) @@ -305,8 +301,7 @@ else if (log?.IsValid == true) @foreach (var mod in log.Mods.Where(p => p.Loaded && !p.IsContentPack)) { - LogModInfo[]? contentPackList; - if (contentPacks == null || !contentPacks.TryGetValue(mod.Name, out contentPackList)) + if (contentPacks == null || !contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList)) contentPackList = null;
@@ -365,14 +364,14 @@ else if (log?.IsValid == true)
- .* - aA - Ab - HL + .* + aA + “ ” + HL
- This website uses JavaScript to display a filterable table. To view this log, please either - view the raw log - or enable JavaScript. + This website uses JavaScript to display a filterable table. To view this log, please enable JavaScript or view the raw log.

diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index 94bc049b..41b54e11 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -113,7 +113,7 @@ table caption { } .table tr { - background: #eee + background: #eee; } #mods span.notice { @@ -148,8 +148,10 @@ table caption { font-style: italic; } -.content-packs--short { +.table .content-packs-collapsed { opacity: 0.75; + font-size: 0.9em; + font-style: italic; } #metadata td:first-child { @@ -157,7 +159,7 @@ table caption { } .table tr:nth-child(even) { - background: #fff + background: #fff; } #filters { diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index af7ceb1e..72cb4a11 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -1,29 +1,15 @@ -/* globals $ */ +/* globals $, Vue */ +/** + * The global SMAPI module. + */ var smapi = smapi || {}; -var app; -var messages; - - -// Necessary helper method for updating our text filter in a performant way. -// Wouldn't want to update it for every individually typed character. -function debounce(fn, delay) { - var timeoutID = null - return function () { - clearTimeout(timeoutID) - var args = arguments - var that = this - timeoutID = setTimeout(function () { - fn.apply(that, args) - }, delay) - } -} -// Case insensitive text searching and match word searching is best done in -// regex, so if the user isn't trying to use regex, escape their input. -function escapeRegex(text) { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} +/** + * The Vue app for the current page. + * @type {Vue} + */ +var app; // Use a scroll event to apply a sticky effect to the filters / pagination // bar. We can't just use "position: sticky" due to how the page is structured @@ -31,65 +17,133 @@ function escapeRegex(text) { $(function () { let sticking = false; - document.addEventListener("scroll", function (event) { + document.addEventListener("scroll", function () { const filters = document.getElementById("filters"); const holder = document.getElementById("filterHolder"); if (!filters || !holder) return; const offset = holder.offsetTop; - const should_stick = window.pageYOffset > offset; - if (should_stick === sticking) + const shouldStick = window.pageYOffset > offset; + if (shouldStick === sticking) return; - sticking = should_stick; + sticking = shouldStick; if (sticking) { holder.style.marginBottom = `calc(1em + ${filters.offsetHeight}px)`; filters.classList.add("sticky"); - } else { + } + else { filters.classList.remove("sticky"); holder.style.marginBottom = ""; } }); }); -// This method is called when we click a log line to toggle the visibility -// of a section. Binding methods is problematic with functional components -// so we just use the `data-section` parameter and our global reference -// to the app. -smapi.clickLogLine = function (event) { - app.toggleSection(event.currentTarget.dataset.section); - event.preventDefault(); - return false; -} - -// And these methods are called when doing pagination. Just makes things -// easier, so may as well use helpers. -smapi.prevPage = function () { - app.prevPage(); -} - -smapi.nextPage = function () { - app.nextPage(); -} - -smapi.changePage = function (event) { - if (typeof event === "number") - app.changePage(event); - else if (event) { - const page = parseInt(event.currentTarget.dataset.page); - if (!isNaN(page) && isFinite(page)) - app.changePage(page); - } -} - - -smapi.logParser = function (state, sectionUrl) { +/** + * Initialize a log parser view on the current page. + * @param {object} state The state options to use. + * @returns {void} + */ +smapi.logParser = function (state) { if (!state) state = {}; + // internal helpers + const helpers = { + /** + * Get a handler which invokes the callback after a set delay, resetting the delay each time it's called. + * @param {(...*) => void} action The callback to invoke when the delay ends. + * @param {number} delay The number of milliseconds to delay the action after each call. + * @returns {() => void} + */ + getDebouncedHandler(action, delay) { + let timeoutId = null; + + return function () { + clearTimeout(timeoutId); + + const args = arguments; + const self = this; + + timeoutId = setTimeout( + function () { + action.apply(self, args); + }, + delay + ); + } + }, + + /** + * Escape regex special characters in the given string. + * @param {string} text + * @returns {string} + */ + escapeRegex(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }, + + /** + * Format a number for the user's locale. + * @param {number} value The number to format. + * @returns {string} + */ + formatNumber(value) { + const formatter = window.Intl && Intl.NumberFormat && new Intl.NumberFormat(); + return formatter && formatter.format + ? formatter.format(value) + : `${value}`; + } + }; + + // internal event handlers + const handlers = { + /** + * Method called when the user clicks a log line to toggle the visibility of a section. Binding methods is problematic with functional components so we just use the `data-section` parameter and our global reference to the app. + * @param {any} event + * @returns {false} + */ + clickLogLine(event) { + app.toggleSection(event.currentTarget.dataset.section); + event.preventDefault(); + return false; + }, + + /** + * Navigate to the previous page of messages in the log. + * @returns {void} + */ + prevPage() { + app.prevPage(); + }, + + /** + * Navigate to the next page of messages in the log. + * @returns {void} + */ + nextPage() { + app.nextPage(); + }, + + /** + * Handle a click on a page number element. + * @param {number | Event} event + * @returns {void} + */ + changePage(event) { + if (typeof event === "number") + app.changePage(event); + else if (event) { + const page = parseInt(event.currentTarget.dataset.page); + if (!isNaN(page) && isFinite(page)) + app.changePage(page); + } + } + }; + // internal filter counts - var stats = state.stats = { + const stats = state.stats = { modsShown: 0, modsHidden: 0 }; @@ -98,7 +152,7 @@ smapi.logParser = function (state, sectionUrl) { // counts stats.modsShown = 0; stats.modsHidden = 0; - for (var key in state.showMods) { + for (let key in state.showMods) { if (state.showMods.hasOwnProperty(key)) { if (state.showMods[key]) stats.modsShown++; @@ -109,14 +163,14 @@ smapi.logParser = function (state, sectionUrl) { } // preprocess data for display - messages = state.data?.messages || []; - if (messages.length) { + state.messages = state.data?.messages || []; + if (state.messages.length) { const levels = state.data.logLevels; const sections = state.data.sections; const modSlugs = state.data.modSlugs; - for (let i = 0, length = messages.length; i < length; i++) { - const message = messages[i]; + for (let i = 0, length = state.messages.length; i < length; i++) { + const message = state.messages[i]; // add unique ID message.id = i; @@ -136,10 +190,10 @@ smapi.logParser = function (state, sectionUrl) { Section: message.Section, Mod: message.Mod, Repeated: message.Repeated, - isRepeated: true, + isRepeated: true }; - messages.splice(i + 1, 0, repeatNote); + state.messages.splice(i + 1, 0, repeatNote); length++; } @@ -147,55 +201,42 @@ smapi.logParser = function (state, sectionUrl) { Object.freeze(message); } } - Object.freeze(messages); + Object.freeze(state.messages); // set local time started if (state.logStarted) state.localTimeStarted = ("0" + state.logStarted.getHours()).slice(-2) + ":" + ("0" + state.logStarted.getMinutes()).slice(-2); - // Add some properties to the data we're passing to Vue. - state.totalMessages = messages.length; - + // add the properties we're passing to Vue + state.totalMessages = state.messages.length; state.filterText = ""; state.filterRegex = ""; - state.showContentPacks = true; state.useHighlight = true; state.useRegex = false; state.useInsensitive = true; state.useWord = false; - state.perPage = 1000; state.page = 1; - // Now load these values. + // load saved values, if any if (localStorage.settings) { try { const saved = JSON.parse(localStorage.settings); - if (saved.hasOwnProperty("showContentPacks")) - state.showContentPacks = saved.showContentPacks; - if (saved.hasOwnProperty("useHighlight")) - dat.useHighlight = saved.useHighlight; - if (saved.hasOwnProperty("useRegex")) - state.useRegex = saved.useRegex; - if (saved.hasOwnProperty("useInsensitive")) - state.useInsensitive = saved.useInsensitive; - if (saved.hasOwnProperty("useWord")) - state.useWord = saved.useWord; - } catch { /* ignore errors */ } - } - // This would be easier if we could just use JSX but this project doesn't - // have a proper JavaScript build environment and I really don't feel - // like setting one up. - - // Add a number formatter so that our numbers look nicer. - const fmt = window.Intl && Intl.NumberFormat && new Intl.NumberFormat(); - function formatNumber(value) { - if (!fmt || !fmt.format) return `${value}`; - return fmt.format(value); + state.showContentPacks = saved.showContentPacks ?? state.showContentPacks; + state.useHighlight = saved.useHighlight ?? state.useHighlight; + state.useRegex = saved.useRegex ?? state.useRegex; + state.useInsensitive = saved.useInsensitive ?? state.useInsensitive; + state.useWord = saved.useWord ?? state.useWord; + } + catch (error) { + // ignore settings if invalid + } } - Vue.filter("number", formatNumber); + + // add a number formatter so our numbers look nicer + Vue.filter("number", handlers.formatNumber); // Strictly speaking, we don't need this. However, due to the way our // Vue template is living in-page the browser is "helpful" and moves @@ -205,11 +246,15 @@ smapi.logParser = function (state, sectionUrl) { Vue.component("log-table", { functional: true, render: function (createElement, context) { - return createElement("table", { - attrs: { - id: "log" - } - }, context.children); + return createElement( + "table", + { + attrs: { + id: "log" + } + }, + context.children + ); } }); @@ -220,29 +265,34 @@ smapi.logParser = function (state, sectionUrl) { functional: true, render: function (createElement, context) { const props = context.props; - if (props.pages > 1) - return createElement("div", { - class: "stats" - }, [ + if (props.pages > 1) { + return createElement( + "div", + { class: "stats" }, + [ + "showing ", + createElement("strong", helpers.formatNumber(props.start + 1)), + " to ", + createElement("strong", helpers.formatNumber(props.end)), + " of ", + createElement("strong", helpers.formatNumber(props.filtered)), + " (total: ", + createElement("strong", helpers.formatNumber(props.total)), + ")" + ] + ); + } + + return createElement( + "div", + { class: "stats" }, + [ "showing ", - createElement("strong", formatNumber(props.start + 1)), - " to ", - createElement("strong", formatNumber(props.end)), - " of ", - createElement("strong", formatNumber(props.filtered)), - " (total: ", - createElement("strong", formatNumber(props.total)), - ")" - ]); - - return createElement("div", { - class: "stats" - }, [ - "showing ", - createElement("strong", formatNumber(props.filtered)), - " out of ", - createElement("strong", formatNumber(props.total)) - ]); + createElement("strong", helpers.formatNumber(props.filtered)), + " out of ", + createElement("strong", helpers.formatNumber(props.total)) + ] + ); } }); @@ -256,15 +306,19 @@ smapi.logParser = function (state, sectionUrl) { links.push(" … "); visited.add(page); - links.push(createElement("span", { - class: page == currentPage ? "active" : null, - attrs: { - "data-page": page + links.push(createElement( + "span", + { + class: page === currentPage ? "active" : null, + attrs: { + "data-page": page + }, + on: { + click: handlers.changePage + } }, - on: { - click: smapi.changePage - } - }, formatNumber(page))); + helpers.formatNumber(page) + )); } Vue.component("pager", { @@ -274,49 +328,55 @@ smapi.logParser = function (state, sectionUrl) { if (props.pages <= 1) return null; - const visited = new Set; + const visited = new Set(); const pageLinks = []; for (let i = 1; i <= 2; i++) addPageLink(i, pageLinks, visited, createElement, props.page); for (let i = props.page - 2; i <= props.page + 2; i++) { - if (i < 1 || i > props.pages) - continue; - - addPageLink(i, pageLinks, visited, createElement, props.page); + if (i >= 1 && i <= props.pages) + addPageLink(i, pageLinks, visited, createElement, props.page); } for (let i = props.pages - 2; i <= props.pages; i++) { - if (i < 1) - continue; - - addPageLink(i, pageLinks, visited, createElement, props.page); + if (i >= 1) + addPageLink(i, pageLinks, visited, createElement, props.page); } - return createElement("div", { - class: "pager" - }, [ - createElement("span", { - class: props.page <= 1 ? "disabled" : null, - on: { - click: smapi.prevPage - } - }, "Prev"), - " ", - "Page ", - formatNumber(props.page), - " of ", - formatNumber(props.pages), - " ", - createElement("span", { - class: props.page >= props.pages ? "disabled" : null, - on: { - click: smapi.nextPage - } - }, "Next"), - createElement("div", {}, pageLinks) - ]); + return createElement( + "div", + { class: "pager" }, + [ + createElement( + "span", + { + class: props.page <= 1 ? "disabled" : null, + on: { + click: handlers.prevPage + } + }, + "Prev" + ), + " ", + "Page ", + helpers.formatNumber(props.page), + " of ", + helpers.formatNumber(props.pages), + " ", + createElement( + "span", + { + class: props.page >= props.pages ? "disabled" : null, + on: { + click: handlers.nextPage + } + }, + "Next" + ), + createElement("div", {}, pageLinks) + ] + ); } }); @@ -342,26 +402,34 @@ smapi.logParser = function (state, sectionUrl) { const level = message.LevelName; if (message.isRepeated) - return createElement("tr", { - class: [ - "mod", - level, - "mod-repeat" + return createElement( + "tr", + { + class: [ + "mod", + level, + "mod-repeat" + ] + }, + [ + createElement( + "td", + { + attrs: { + colspan: context.props.showScreenId ? 4 : 3 + } + }, + "" + ), + createElement("td", `repeats ${message.Repeated} times`) ] - }, [ - createElement("td", { - attrs: { - colspan: context.props.showScreenId ? 4 : 3 - } - }, ""), - createElement("td", `repeats ${message.Repeated} times`) - ]); + ); const events = {}; let toggleMessage; if (message.IsStartOfSection) { const visible = message.SectionName && window.app && app.sectionsAllow(message.SectionName); - events.click = smapi.clickLogLine; + events.click = handlers.clickLogLine; toggleMessage = visible ? "This section is shown. Click here to hide it." : "This section is hidden. Click here to show it."; @@ -371,7 +439,9 @@ smapi.logParser = function (state, sectionUrl) { const filter = window.app && app.filterRegex; if (text && filter && context.props.highlight) { text = []; - let match, consumed = 0, idx = 0; + let match; + let consumed = 0; + let index = 0; filter.lastIndex = -1; // Our logic to highlight the text is a bit funky because we @@ -379,64 +449,85 @@ smapi.logParser = function (state, sectionUrl) { // where a ton of single characters are in their own elements // if the user gives us bad input. - while (match = filter.exec(message.Text)) { + while (true) { + match = filter.exec(message.Text); + if (!match) + break; + // Do we have an area of non-matching text? This // happens if the new match's index is further // along than the last index. - if (match.index > idx) { + if (match.index > index) { // Alright, do we have a previous match? If // we do, we need to consume some text. - if (consumed < idx) - text.push(createElement("strong", {}, message.Text.slice(consumed, idx))); + if (consumed < index) + text.push(createElement("strong", {}, message.Text.slice(consumed, index))); - text.push(message.Text.slice(idx, match.index)); + text.push(message.Text.slice(index, match.index)); consumed = match.index; } - idx = match.index + match[0].length; + index = match.index + match[0].length; } // Add any trailing text after the last match was found. if (consumed < message.Text.length) { - if (consumed < idx) - text.push(createElement("strong", {}, message.Text.slice(consumed, idx))); + if (consumed < index) + text.push(createElement("strong", {}, message.Text.slice(consumed, index))); - if (idx < message.Text.length) - text.push(message.Text.slice(idx)); + if (index < message.Text.length) + text.push(message.Text.slice(index)); } } - return createElement("tr", { - class: [ - "mod", - level, - message.IsStartOfSection ? "section-start" : null - ], - attrs: { - "data-section": message.SectionName - }, - on: events - }, [ - createElement("td", message.Time), - context.props.showScreenId ? createElement("td", message.ScreenId) : null, - createElement("td", level.toUpperCase()), - createElement("td", { + return createElement( + "tr", + { + class: [ + "mod", + level, + message.IsStartOfSection ? "section-start" : null + ], attrs: { - "data-title": message.Mod - } - }, message.Mod), - createElement("td", [ - createElement("span", { - class: "log-message-text" - }, text), - message.IsStartOfSection ? createElement("span", { - class: "section-toggle-message" - }, [ - " ", - toggleMessage - ]) : null - ]) - ]); + "data-section": message.SectionName + }, + on: events + }, + [ + createElement("td", message.Time), + context.props.showScreenId ? createElement("td", message.ScreenId) : null, + createElement("td", level.toUpperCase()), + createElement( + "td", + { + attrs: { + "data-title": message.Mod + } + }, + message.Mod + ), + createElement( + "td", + [ + createElement( + "span", + { class: "log-message-text" }, + text + ), + message.IsStartOfSection + ? createElement( + "span", + { class: "section-toggle-message" }, + [ + " ", + toggleMessage + ] + ) + : null + ] + ) + ] + ); } }); @@ -463,44 +554,53 @@ smapi.logParser = function (state, sectionUrl) { }, // Filter messages for visibility. - filterUseRegex: function () { return state.useRegex; }, - filterInsensitive: function () { return state.useInsensitive; }, - filterUseWord: function () { return state.useWord; }, - shouldHighlight: function () { return state.useHighlight; }, + filterUseRegex: function () { + return state.useRegex; + }, + filterInsensitive: function () { + return state.useInsensitive; + }, + filterUseWord: function () { + return state.useWord; + }, + shouldHighlight: function () { + return state.useHighlight; + }, filteredMessages: function () { - if (!messages) + if (!state.messages) return []; const start = performance.now(); - const ret = []; + const filtered = []; // This is slightly faster than messages.filter(), which is // important when working with absolutely huge logs. - for (let i = 0, length = messages.length; i < length; i++) { - const msg = messages[i]; + for (let i = 0, length = state.messages.length; i < length; i++) { + const msg = state.messages[i]; if (msg.SectionName && !msg.IsStartOfSection && !this.sectionsAllow(msg.SectionName)) continue; if (!this.filtersAllow(msg.ModSlug, msg.LevelName)) continue; - let text = msg.Text || (i > 0 ? messages[i - 1].Text : null); + const text = msg.Text || (i > 0 ? state.messages[i - 1].Text : null); if (this.filterRegex) { this.filterRegex.lastIndex = -1; if (!text || !this.filterRegex.test(text)) continue; - } else if (this.filterText && (!text || text.indexOf(this.filterText) == -1)) + } + else if (this.filterText && (!text || text.indexOf(this.filterText) === -1)) continue; - ret.push(msg); + filtered.push(msg); } const end = performance.now(); - console.log(`filter took ${end - start}ms`); + //console.log(`applied ${(this.filterRegex ? "regex" : "text")} filter '${this.filterRegex || this.filterText}' in ${end - start}ms`); - return ret; + return filtered; }, // And the rest are about pagination. @@ -525,8 +625,7 @@ smapi.logParser = function (state, sectionUrl) { } }, created: function () { - this.loadFromUrl = this.loadFromUrl.bind(this); - window.addEventListener("popstate", this.loadFromUrl); + window.addEventListener("popstate", () => this.loadFromUrl()); this.loadFromUrl(); }, methods: { @@ -536,19 +635,17 @@ smapi.logParser = function (state, sectionUrl) { // user can link to their exact page state for someone else? loadFromUrl: function () { const params = new URL(location).searchParams; - if (params.has("PerPage")) - try { - const perPage = parseInt(params.get("PerPage")); - if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) - state.perPage = perPage; - } catch { /* ignore errors */ } + if (params.has("PerPage")) { + const perPage = parseInt(params.get("PerPage")); + if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) + state.perPage = perPage; + } - if (params.has("Page")) - try { - const page = parseInt(params.get("Page")); - if (!isNaN(page) && isFinite(page) && page > 0) - this.page = page; - } catch { /* ignore errors */ } + if (params.has("Page")) { + const page = parseInt(params.get("Page")); + if (!isNaN(page) && isFinite(page) && page > 0) + this.page = page; + } }, toggleLevel: function (id) { @@ -635,26 +732,30 @@ smapi.logParser = function (state, sectionUrl) { // a quarter second delay. We basically always build a regular expression // since we use it for highlighting, and it also make case insensitivity // much easier. - updateFilterText: debounce(function () { - let text = this.filterText = document.querySelector("input[type=text]").value; - if (!text || !text.length) { - this.filterText = ""; - this.filterRegex = null; - } else { - if (!state.useRegex) - text = escapeRegex(text); - this.filterRegex = new RegExp( - state.useWord ? `\\b${text}\\b` : text, - state.useInsensitive ? "ig" : "g" - ); - } - }, 250), + updateFilterText: helpers.getDebouncedHandler( + function () { + let text = this.filterText = document.querySelector("input[type=text]").value; + if (!text || !text.length) { + this.filterText = ""; + this.filterRegex = null; + } + else { + if (!state.useRegex) + text = helpers.escapeRegex(text); + this.filterRegex = new RegExp( + state.useWord ? `\\b${text}\\b` : text, + state.useInsensitive ? "ig" : "g" + ); + } + }, + 250 + ), toggleMod: function (id) { if (!state.enableFilters) return; - var curShown = this.showMods[id]; + const curShown = this.showMods[id]; // first filter: only show this by default if (stats.modsHidden === 0) { @@ -684,7 +785,7 @@ smapi.logParser = function (state, sectionUrl) { if (!state.enableFilters) return; - for (var key in this.showMods) { + for (let key in this.showMods) { if (this.showMods.hasOwnProperty(key)) { this.showMods[key] = true; } @@ -696,7 +797,7 @@ smapi.logParser = function (state, sectionUrl) { if (!state.enableFilters) return; - for (var key in this.showMods) { + for (let key in this.showMods) { if (this.showMods.hasOwnProperty(key)) { this.showMods[key] = false; } @@ -717,7 +818,7 @@ smapi.logParser = function (state, sectionUrl) { /********** ** Upload form *********/ - var input = $("#input"); + const input = $("#input"); if (input.length) { // file upload smapi.fileUpload({ diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.sln.DotSettings index 5b35c615..5cb13525 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -31,6 +31,8 @@ True True True + True + True True True True -- cgit From 07d07c79e00906f826cb6d3e7532f4a2ad2099ba Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 16:01:32 -0400 Subject: load raw data from JSON per discussion This avoids loading the data synchronously as a JavaScript snippet, which improves performance when opening the page. --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 45 +++++++++++++++++--------- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 8 ++++- 2 files changed, 36 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 2d5dd403..11f15403 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -42,27 +42,40 @@ + + + - + } @@ -378,6 +383,7 @@ else if (log?.IsValid == true) @@ -390,7 +396,7 @@ else if (log?.IsValid == true) v-bind:start="start" v-bind:end="end" v-bind:pages="totalPages" - v-bind:filtered="filteredMessages.length" + v-bind:filtered="filteredMessages.total" v-bind:total="totalMessages" /> diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 59c6026c..69f0a46d 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -94,7 +94,93 @@ smapi.logParser = function (state) { return formatter && formatter.format ? formatter.format(value) : `${value}`; + }, + + /** + * Convert an array of boolean values into a bitmap. + * @param {Boolean[]} value An array of boolean values + * @returns {BigInt} + */ + toBitmap(value) { + let result = BigInt(0); + if (!Array.isArray(value)) + return value ? BigInt(1) : BigInt(0); + + for (let i = 0; i < value.length; i++) { + if (value[i]) + result += BigInt(2) ** BigInt(value.length - i - 1); + } + + return result; + }, + + /** + * Convert a bitmap into an array of boolean values. + * @param {BigInt} value The bitmap + * @param {Number} length The expected length of the result + * @returns {Boolean[]} + */ + fromBitmap(value, length = -1) { + if (typeof value != "bigint") + value = ""; + else + value = value.toString(2); + + const result = []; + while (length > value.length) { + result.push(false); + length--; + } + + for (let i = 0; i < value.length; i++) { + result.push(value[i] === "1" ? true : false); + } + + return result; + }, + + b64ToBigInt(value) { + const bin = atob(value); + const hex = []; + + for (let i = 0; i < bin.length; i++) { + let h = bin.charCodeAt(i).toString(16); + if (h.length % 2) h = `0${h}`; + hex.push(h); + } + + return BigInt(`0x${hex.join('')}`); + }, + + bigIntTo64(value) { + let hex = value.toString(16); + if (hex.length % 2) hex = `0${hex}`; + + const result = []; + for (let i = 0; i < hex.length; i += 2) { + const val = parseInt(hex.slice(i, i + 2), 16); + result.push(String.fromCharCode(val)); + } + + return btoa(result.join('')); + }, + + b64ToUrl(value) { + return value.replace(/\//g, '_').replace(/=/g, '-').replace(/\+/g, '.'); + }, + + urlTob64(value) { + return value.replace(/_/g, '/').replace(/-/g, '=').replace(/\./g, '+'); + }, + + toUrlBitmap(value) { + return helpers.b64ToUrl(helpers.bigIntTo64(helpers.toBitmap(value))); + }, + + fromUrlBitmap(value, length = -1) { + return helpers.fromBitmap(helpers.b64ToBigInt(helpers.urlTob64(value)), length); } + }; // internal event handlers @@ -272,32 +358,19 @@ smapi.logParser = function (state) { functional: true, render: function (createElement, context) { const props = context.props; - if (props.pages > 1) { - return createElement( - "div", - { class: "stats" }, - [ - "showing ", - createElement("strong", helpers.formatNumber(props.start + 1)), - " to ", - createElement("strong", helpers.formatNumber(props.end)), - " of ", - createElement("strong", helpers.formatNumber(props.filtered)), - " (total: ", - createElement("strong", helpers.formatNumber(props.total)), - ")" - ] - ); - } - return createElement( "div", { class: "stats" }, [ "showing ", + createElement("strong", helpers.formatNumber(props.start + 1)), + " to ", + createElement("strong", helpers.formatNumber(props.end)), + " of ", createElement("strong", helpers.formatNumber(props.filtered)), - " out of ", - createElement("strong", helpers.formatNumber(props.total)) + " (total: ", + createElement("strong", helpers.formatNumber(props.total)), + ")" ] ); } @@ -578,34 +651,39 @@ smapi.logParser = function (state) { if (!state.messages) return []; - const start = performance.now(); + //const start = performance.now(); const filtered = []; + let total = 0; + // This is slightly faster than messages.filter(), which is // important when working with absolutely huge logs. for (let i = 0, length = state.messages.length; i < length; i++) { const msg = state.messages[i]; - if (msg.SectionName && !msg.IsStartOfSection && !this.sectionsAllow(msg.SectionName)) - continue; - if (!this.filtersAllow(msg.ModSlug, msg.LevelName)) continue; - const text = msg.Text || (i > 0 ? state.messages[i - 1].Text : null); - if (this.filterRegex) { + const text = msg.Text || (i > 0 ? state.messages[i - 1].Text : null); this.filterRegex.lastIndex = -1; if (!text || !this.filterRegex.test(text)) continue; } - else if (this.filterText && (!text || text.indexOf(this.filterText) === -1)) + + total++; + + if (msg.SectionName && !msg.IsStartOfSection && !this.sectionsAllow(msg.SectionName)) continue; filtered.push(msg); } - const end = performance.now(); - //console.log(`applied ${(this.filterRegex ? "regex" : "text")} filter '${this.filterRegex || this.filterText}' in ${end - start}ms`); + filtered.total = total; + + Object.freeze(filtered); + + //const end = performance.now(); + //console.log(`applied ${(this.useRegex ? "regex" : "text")} filter '${this.filterRegex}' in ${end - start}ms`); return filtered; }, @@ -636,10 +714,6 @@ smapi.logParser = function (state) { this.loadFromUrl(); }, methods: { - // Mostly I wanted people to know they can override the PerPage - // message count with a URL parameter, but we can read Page too. - // In the future maybe we should read *all* filter state so a - // user can link to their exact page state for someone else? loadFromUrl: function () { const params = new URL(location).searchParams; if (params.has("PerPage")) { @@ -653,6 +727,78 @@ smapi.logParser = function (state) { if (!isNaN(page) && isFinite(page) && page > 0) this.page = page; } + + let updateFilter = false; + + if (params.has("Filter")) { + state.filterText = params.get("Filter"); + updateFilter = true; + } + + if (params.has("FilterMode")) { + const values = helpers.fromUrlBitmap(params.get("FilterMode"), 3); + state.useRegex = values[0]; + state.useInsensitive = values[1]; + state.useWord = values[2]; + updateFilter = true; + } + + if (params.has("Mods")) { + const keys = Object.keys(this.showMods); + const value = params.get("Mods"); + const values = value === "all" ? true : value === "none" ? false : helpers.fromUrlBitmap(value, keys.length); + + for (let i = 0; i < keys.length; i++) { + this.showMods[keys[i]] = Array.isArray(values) ? values[i] : values; + } + + updateModFilters(); + } + + if (params.has("Levels")) { + const keys = Object.keys(this.showLevels); + const values = helpers.fromUrlBitmap(params.get("Levels"), keys.length); + + for (let i = 0; i < keys.length; i++) { + this.showLevels[keys[i]] = values[i]; + } + } + + if (params.has("Sections")) { + const keys = Object.keys(this.showSections); + const values = helpers.fromUrlBitmap(params.get("Levels"), keys.length); + + for (let i = 0; i < keys.length; i++) { + this.showSections[keys[i]] = values[i]; + } + } + + if (updateFilter) + this.updateFilterText(); + }, + + // Whenever the page state changed, replace the current page URL. Using + // replaceState rather than pushState to avoid filling the tab history + // with tons of useless history steps the user probably doesn't + // really care about. + updateUrl: function () { + const url = new URL(location); + url.searchParams.set("Page", state.page); + url.searchParams.set("PerPage", state.perPage); + + url.searchParams.set("Mods", stats.modsHidden == 0 ? "all" : stats.modsShown == 0 ? "none" : helpers.toUrlBitmap(Object.values(this.showMods))); + url.searchParams.set("Levels", helpers.toUrlBitmap(Object.values(this.showLevels))); + url.searchParams.set("Sections", helpers.toUrlBitmap(Object.values(this.showSections))); + + if (state.filterText && state.filterText.length) { + url.searchParams.set("Filter", state.filterText); + url.searchParams.set("FilterMode", helpers.toUrlBitmap([state.useRegex, state.useInsensitive, state.useWord])); + } else { + url.searchParams.delete("Filter"); + url.searchParams.delete("FilterMode"); + } + + window.history.replaceState(null, document.title, url.toString()); }, toggleLevel: function (id) { @@ -660,6 +806,7 @@ smapi.logParser = function (state) { return; this.showLevels[id] = !this.showLevels[id]; + this.updateUrl(); }, toggleContentPacks: function () { @@ -723,25 +870,13 @@ smapi.logParser = function (state) { }); }, - // Whenever the page is changed, replace the current page URL. Using - // replaceState rather than pushState to avoid filling the tab history - // with tons of useless history steps the user probably doesn't - // really care about. - updateUrl: function () { - const url = new URL(location); - url.searchParams.set("Page", state.page); - url.searchParams.set("PerPage", state.perPage); - - window.history.replaceState(null, document.title, url.toString()); - }, - // We don't want to update the filter text often, so use a debounce with // a quarter second delay. We basically always build a regular expression // since we use it for highlighting, and it also make case insensitivity // much easier. updateFilterText: helpers.getDebouncedHandler( function () { - let text = this.filterText = document.querySelector("input[type=text]").value; + let text = state.filterText; if (!text || !text.length) { this.filterText = ""; this.filterRegex = null; @@ -754,6 +889,8 @@ smapi.logParser = function (state) { state.useInsensitive ? "ig" : "g" ); } + + this.updateUrl(); }, 250 ), @@ -779,6 +916,7 @@ smapi.logParser = function (state) { this.showMods[id] = !this.showMods[id]; updateModFilters(); + this.updateUrl(); }, toggleSection: function (name) { @@ -786,6 +924,7 @@ smapi.logParser = function (state) { return; this.showSections[name] = !this.showSections[name]; + this.updateUrl(); }, showAllMods: function () { @@ -798,6 +937,7 @@ smapi.logParser = function (state) { } } updateModFilters(); + this.updateUrl(); }, hideAllMods: function () { @@ -810,6 +950,7 @@ smapi.logParser = function (state) { } } updateModFilters(); + this.updateUrl(); }, filtersAllow: function (modId, level) { -- cgit From 94b8507a4763020c578e98ecf5af645fe6583cee Mon Sep 17 00:00:00 2001 From: Khloe Leclair Date: Mon, 11 Apr 2022 15:01:59 -0400 Subject: Add more documentation strings. Use shallow equality checking to decide whether to include a filter in the URL or not to avoid unnecessarily large URLs. --- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 90 ++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 69f0a46d..8538423f 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -139,6 +139,11 @@ smapi.logParser = function (state) { return result; }, + /** + * Convert a base-64 string to a BigInt. + * @param {string} value + * @returns {BigInt} + */ b64ToBigInt(value) { const bin = atob(value); const hex = []; @@ -152,6 +157,11 @@ smapi.logParser = function (state) { return BigInt(`0x${hex.join('')}`); }, + /** + * Convert a BigInt to a base-64 string. + * @param {BigInt} value + * @returns {string} + */ bigIntTo64(value) { let hex = value.toString(16); if (hex.length % 2) hex = `0${hex}`; @@ -165,22 +175,79 @@ smapi.logParser = function (state) { return btoa(result.join('')); }, + /** + * Make a base-64 string URL safe. + * @param {string} value + * @returns {string} + */ b64ToUrl(value) { return value.replace(/\//g, '_').replace(/=/g, '-').replace(/\+/g, '.'); }, + /** + * Convert a URL safe base-64 string back to normal. + * @param {string} value + * @returns {string} + */ urlTob64(value) { return value.replace(/_/g, '/').replace(/-/g, '=').replace(/\./g, '+'); }, + /** + * Convert an array of booleans to a BigInt bitmap, then convert that + * to a base-64 string, then make it URL safe. + * @param {Boolean[]} value + * @returns {string} + */ toUrlBitmap(value) { return helpers.b64ToUrl(helpers.bigIntTo64(helpers.toBitmap(value))); }, + /** + * Convert a URL safe base-64 string to a normal base-64 string, convert + * that to a BigInt, and then parse a bitmap from the BigInt. + * @param {string} value + * @param {Number} length The expected length of the bitmap. + */ fromUrlBitmap(value, length = -1) { return helpers.fromBitmap(helpers.b64ToBigInt(helpers.urlTob64(value)), length); - } + }, + /** + * Check the shallow equality of two objects. + * @param {Array} first + * @param {Array} second + * @returns {Boolean} + */ + shallowEquals(first, second) { + if (typeof first !== "object" || typeof second !== "object") + return first === second; + + if (first == null || second == null) + return first == null && second == null; + + const f_array = Array.isArray(first); + const s_array = Array.isArray(second); + + if (f_array !== s_array) + return false; + + const f_keys = Object.keys(first); + const s_keys = Object.keys(second); + + if (f_keys.length != s_keys.length) + return false; + + for (const key of f_keys) { + if (!s_keys.includes(key)) + return false; + + if (first[key] !== second[key]) + return false; + } + + return true; + } }; // internal event handlers @@ -312,6 +379,10 @@ smapi.logParser = function (state) { state.perPage = 1000; state.page = 1; + state.defaultMods = JSON.parse(JSON.stringify(state.showMods)); + state.defaultSections = JSON.parse(JSON.stringify(state.showSections)); + state.defaultLevels = JSON.parse(JSON.stringify(state.showLevels)); + // load saved values, if any if (localStorage.settings) { try { @@ -786,9 +857,20 @@ smapi.logParser = function (state) { url.searchParams.set("Page", state.page); url.searchParams.set("PerPage", state.perPage); - url.searchParams.set("Mods", stats.modsHidden == 0 ? "all" : stats.modsShown == 0 ? "none" : helpers.toUrlBitmap(Object.values(this.showMods))); - url.searchParams.set("Levels", helpers.toUrlBitmap(Object.values(this.showLevels))); - url.searchParams.set("Sections", helpers.toUrlBitmap(Object.values(this.showSections))); + if (!helpers.shallowEquals(this.showMods, state.defaultMods)) + url.searchParams.set("Mods", stats.modsHidden == 0 ? "all" : stats.modsShown == 0 ? "none" : helpers.toUrlBitmap(Object.values(this.showMods))); + else + url.searchParams.delete("Mods"); + + if (!helpers.shallowEquals(this.showLevels, state.defaultLevels)) + url.searchParams.set("Levels", helpers.toUrlBitmap(Object.values(this.showLevels))); + else + url.searchParams.delete("Levels"); + + if (!helpers.shallowEquals(this.showSections, state.defaultSections)) + url.searchParams.set("Sections", helpers.toUrlBitmap(Object.values(this.showSections))); + else + url.searchParams.delete("Sections"); if (state.filterText && state.filterText.length) { url.searchParams.set("Filter", state.filterText); -- cgit From 1e61309d3dce484d5b646b1a8d15c825beac813f Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Mon, 11 Apr 2022 22:56:14 -0400 Subject: add IAssetDataForMap.ExtendMap --- docs/release-notes.md | 1 + src/SMAPI/Framework/Content/AssetDataForMap.cs | 54 +++++++++++++++++++++- src/SMAPI/Framework/Content/AssetDataForObject.cs | 23 +++++++-- src/SMAPI/Framework/ContentCoordinator.cs | 3 +- .../ContentManagers/BaseContentManager.cs | 4 ++ .../ContentManagers/GameContentManager.cs | 6 +-- src/SMAPI/Framework/ModHelpers/ContentHelper.cs | 16 ++++++- .../Framework/ModHelpers/GameContentHelper.cs | 16 ++++++- src/SMAPI/Framework/ModHelpers/ModContentHelper.cs | 16 ++++++- src/SMAPI/Framework/SCore.cs | 14 +++--- src/SMAPI/IAssetDataForImage.cs | 4 +- src/SMAPI/IAssetDataForMap.cs | 7 +++ 12 files changed, 138 insertions(+), 26 deletions(-) (limited to 'src') diff --git a/docs/release-notes.md b/docs/release-notes.md index 0687b888..7379cba3 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -45,6 +45,7 @@ the C# mod that loads them is updated. _This adds support for many previously unsupported cases: proxied interfaces in return values or input arguments, proxied enums if their values match, generic methods, and more. Existing mod APIs should work fine as-is._ * Mod files loaded through SMAPI APIs (including `helper.Content.Load`) are now case-insensitive, even on Linux. * Other improvements: + * Added `IAssetDataForImage.ExtendMap` to resize maps in asset editors. * Added [command-line arguments](technical/smapi.md#command-line-arguments) to toggle developer mode (thanks to Tondorian!). * Added `IContentPack.ModContent` property. * Added `Constants.ContentPath`. diff --git a/src/SMAPI/Framework/Content/AssetDataForMap.cs b/src/SMAPI/Framework/Content/AssetDataForMap.cs index 0425e195..93148277 100644 --- a/src/SMAPI/Framework/Content/AssetDataForMap.cs +++ b/src/SMAPI/Framework/Content/AssetDataForMap.cs @@ -4,17 +4,27 @@ using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using xTile; +using xTile.Dimensions; using xTile.Layers; using xTile.Tiles; +using Rectangle = Microsoft.Xna.Framework.Rectangle; namespace StardewModdingAPI.Framework.Content { /// Encapsulates access and changes to image content being read from a data file. internal class AssetDataForMap : AssetData, IAssetDataForMap { + /********* + ** Fields + *********/ + /// Simplifies access to private code. + private readonly Reflector Reflection; + + /********* ** Public methods *********/ @@ -24,8 +34,12 @@ namespace StardewModdingAPI.Framework.Content /// The content data being read. /// Normalizes an asset key to match the cache key. /// A callback to invoke when the data is replaced (if any). - public AssetDataForMap(string locale, IAssetName assetName, Map data, Func getNormalizedPath, Action onDataReplaced) - : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } + /// Simplifies access to private code. + public AssetDataForMap(string locale, IAssetName assetName, Map data, Func getNormalizedPath, Action onDataReplaced, Reflector reflection) + : base(locale, assetName, data, getNormalizedPath, onDataReplaced) + { + this.Reflection = reflection; + } /// /// Derived from with a few changes: @@ -137,6 +151,42 @@ namespace StardewModdingAPI.Framework.Content } } + /// + public bool ExtendMap(Map map, int minWidth, int minHeight) + { + bool resized = false; + + // resize layers + foreach (Layer layer in map.Layers) + { + // check if resize needed + if (layer.LayerWidth >= minWidth && layer.LayerHeight >= minHeight) + continue; + resized = true; + + // build new tile matrix + int width = Math.Max(minWidth, layer.LayerWidth); + int height = Math.Max(minHeight, layer.LayerHeight); + Tile[,] tiles = new Tile[width, height]; + for (int x = 0; x < layer.LayerWidth; x++) + { + for (int y = 0; y < layer.LayerHeight; y++) + tiles[x, y] = layer.Tiles[x, y]; + } + + // update fields + this.Reflection.GetField(layer, "m_tiles").SetValue(tiles); + this.Reflection.GetField(layer, "m_tileArray").SetValue(new TileArray(layer, tiles)); + this.Reflection.GetField(layer, "m_layerSize").SetValue(new Size(width, height)); + } + + // resize map + if (resized) + this.Reflection.GetMethod(map, "UpdateDisplaySize").Invoke(); + + return resized; + } + /********* ** Private methods diff --git a/src/SMAPI/Framework/Content/AssetDataForObject.cs b/src/SMAPI/Framework/Content/AssetDataForObject.cs index 4a6df64b..bb3966b9 100644 --- a/src/SMAPI/Framework/Content/AssetDataForObject.cs +++ b/src/SMAPI/Framework/Content/AssetDataForObject.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Reflection; using xTile; namespace StardewModdingAPI.Framework.Content @@ -10,6 +11,13 @@ namespace StardewModdingAPI.Framework.Content /// Encapsulates access and changes to content being read from a data file. internal class AssetDataForObject : AssetData, IAssetData { + /********* + ** Fields + *********/ + /// Simplifies access to private code. + private readonly Reflector Reflection; + + /********* ** Public methods *********/ @@ -18,15 +26,20 @@ namespace StardewModdingAPI.Framework.Content /// The asset name being read. /// The content data being read. /// Normalizes an asset key to match the cache key. - public AssetDataForObject(string locale, IAssetName assetName, object data, Func getNormalizedPath) - : base(locale, assetName, data, getNormalizedPath, onDataReplaced: null) { } + /// Simplifies access to private code. + public AssetDataForObject(string locale, IAssetName assetName, object data, Func getNormalizedPath, Reflector reflection) + : base(locale, assetName, data, getNormalizedPath, onDataReplaced: null) + { + this.Reflection = reflection; + } /// Construct an instance. /// The asset metadata. /// The content data being read. /// Normalizes an asset key to match the cache key. - public AssetDataForObject(IAssetInfo info, object data, Func getNormalizedPath) - : this(info.Locale, info.Name, data, getNormalizedPath) { } + /// Simplifies access to private code. + public AssetDataForObject(IAssetInfo info, object data, Func getNormalizedPath, Reflector reflection) + : this(info.Locale, info.Name, data, getNormalizedPath, reflection) { } /// public IAssetDataForDictionary AsDictionary() @@ -43,7 +56,7 @@ namespace StardewModdingAPI.Framework.Content /// public IAssetDataForMap AsMap() { - return new AssetDataForMap(this.Locale, this.Name, this.GetData(), this.GetNormalizedPath, this.ReplaceWith); + return new AssetDataForMap(this.Locale, this.Name, this.GetData(), this.GetNormalizedPath, this.ReplaceWith, this.Reflection); } /// diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 81820b05..4e48b08c 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -727,7 +727,8 @@ namespace StardewModdingAPI.Framework locale: null, assetName: legacyName, data: asset.Data, - getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName + getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName, + reflection: this.Reflection ); } diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index 4594d235..f1ccab48 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -32,6 +32,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// Encapsulates monitoring and logging. protected readonly IMonitor Monitor; + /// Simplifies access to private code. + protected readonly Reflector Reflection; + /// Whether to enable more aggressive memory optimizations. protected readonly bool AggressiveMemoryOptimizations; @@ -90,6 +93,7 @@ namespace StardewModdingAPI.Framework.ContentManagers this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); this.Cache = new ContentCache(this, reflection); this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); + this.Reflection = reflection; this.OnDisposing = onDisposing; this.IsNamespaced = isNamespaced; this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations; diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index f4e1bda4..e494559d 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -124,7 +124,7 @@ namespace StardewModdingAPI.Framework.ContentManagers IAssetInfo info = new AssetInfo(assetName.LocaleCode, assetName, typeof(T), this.AssertAndNormalizeAssetName); IAssetData asset = this.ApplyLoader(info) - ?? new AssetDataForObject(info, this.RawLoad(assetName, useCache), this.AssertAndNormalizeAssetName); + ?? new AssetDataForObject(info, this.RawLoad(assetName, useCache), this.AssertAndNormalizeAssetName, this.Reflection); asset = this.ApplyEditors(info, asset); return (T)asset.Data; }); @@ -187,7 +187,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // return matched asset return this.TryFixAndValidateLoadedAsset(info, data, loader) - ? new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName) + ? new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName, this.Reflection) : null; } @@ -197,7 +197,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /// The loaded asset. private IAssetData ApplyEditors(IAssetInfo info, IAssetData asset) { - IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName); + IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName, this.Reflection); // special case: if the asset was loaded with a more general type like 'object', call editors with the actual type instead. { diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index e72e397e..12ef0439 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -10,6 +10,7 @@ using System.Linq; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Reflection; using StardewValley; namespace StardewModdingAPI.Framework.ModHelpers @@ -36,6 +37,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Encapsulates monitoring and logging. private readonly IMonitor Monitor; + /// Simplifies access to private code. + private readonly Reflector Reflection; + /********* ** Accessors @@ -94,7 +98,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The unique ID of the relevant mod. /// The friendly mod name for use in errors. /// Encapsulates monitoring and logging. - public ContentHelper(ContentCoordinator contentCore, string modFolderPath, string modID, string modName, IMonitor monitor) + /// Simplifies access to private code. + public ContentHelper(ContentCoordinator contentCore, string modFolderPath, string modID, string modName, IMonitor monitor, Reflector reflection) : base(modID) { string managedAssetPrefix = contentCore.GetManagedAssetPrefix(modID); @@ -104,6 +109,7 @@ namespace StardewModdingAPI.Framework.ModHelpers this.ModContentManager = contentCore.CreateModContentManager(managedAssetPrefix, modName, modFolderPath, this.GameContentManager); this.ModName = modName; this.Monitor = monitor; + this.Reflection = reflection; } /// @@ -185,7 +191,13 @@ namespace StardewModdingAPI.Framework.ModHelpers assetName ??= $"temp/{Guid.NewGuid():N}"; - return new AssetDataForObject(this.CurrentLocale, this.ContentCore.ParseAssetName(assetName, allowLocales: true/* no way to know if it's a game or mod asset here*/), data, this.NormalizeAssetName); + return new AssetDataForObject( + locale: this.CurrentLocale, + assetName: this.ContentCore.ParseAssetName(assetName, allowLocales: true/* no way to know if it's a game or mod asset here*/), + data: data, + getNormalizedPath: this.NormalizeAssetName, + reflection: this.Reflection + ); } diff --git a/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs b/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs index 956bac7f..6d0c2f5f 100644 --- a/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs @@ -5,6 +5,7 @@ using System.Linq; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Reflection; using StardewValley; namespace StardewModdingAPI.Framework.ModHelpers @@ -27,6 +28,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// Encapsulates monitoring and logging. private readonly IMonitor Monitor; + /// Simplifies access to private code. + private readonly Reflector Reflection; + /********* ** Accessors @@ -46,7 +50,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The unique ID of the relevant mod. /// The friendly mod name for use in errors. /// Encapsulates monitoring and logging. - public GameContentHelper(ContentCoordinator contentCore, string modID, string modName, IMonitor monitor) + /// Simplifies access to private code. + public GameContentHelper(ContentCoordinator contentCore, string modID, string modName, IMonitor monitor, Reflector reflection) : base(modID) { string managedAssetPrefix = contentCore.GetManagedAssetPrefix(modID); @@ -55,6 +60,7 @@ namespace StardewModdingAPI.Framework.ModHelpers this.GameContentManager = contentCore.CreateGameContentManager(managedAssetPrefix + ".content"); this.ModName = modName; this.Monitor = monitor; + this.Reflection = reflection; } /// @@ -119,7 +125,13 @@ namespace StardewModdingAPI.Framework.ModHelpers assetName ??= $"temp/{Guid.NewGuid():N}"; - return new AssetDataForObject(this.CurrentLocale, this.ContentCore.ParseAssetName(assetName, allowLocales: true), data, key => this.ParseAssetName(key).Name); + return new AssetDataForObject( + locale: this.CurrentLocale, + assetName: this.ContentCore.ParseAssetName(assetName, allowLocales: true), + data: data, + getNormalizedPath: key => this.ParseAssetName(key).Name, + reflection: this.Reflection + ); } /// Get the underlying game content manager. diff --git a/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs index 90064354..b149ed82 100644 --- a/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs @@ -5,6 +5,7 @@ using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Utilities; namespace StardewModdingAPI.Framework.ModHelpers @@ -27,6 +28,9 @@ namespace StardewModdingAPI.Framework.ModHelpers /// A case-insensitive lookup of relative paths within the . private readonly CaseInsensitivePathCache RelativePathCache; + /// Simplifies access to private code. + private readonly Reflector Reflection; + /********* ** Public methods @@ -38,7 +42,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The friendly mod name for use in errors. /// The game content manager used for map tilesheets not provided by the mod. /// A case-insensitive lookup of relative paths within the . - public ModContentHelper(ContentCoordinator contentCore, string modFolderPath, string modID, string modName, IContentManager gameContentManager, CaseInsensitivePathCache relativePathCache) + /// Simplifies access to private code. + public ModContentHelper(ContentCoordinator contentCore, string modFolderPath, string modID, string modName, IContentManager gameContentManager, CaseInsensitivePathCache relativePathCache, Reflector reflection) : base(modID) { string managedAssetPrefix = contentCore.GetManagedAssetPrefix(modID); @@ -47,6 +52,7 @@ namespace StardewModdingAPI.Framework.ModHelpers this.ModContentManager = contentCore.CreateModContentManager(managedAssetPrefix, modName, modFolderPath, gameContentManager); this.ModName = modName; this.RelativePathCache = relativePathCache; + this.Reflection = reflection; } /// @@ -83,7 +89,13 @@ namespace StardewModdingAPI.Framework.ModHelpers ? this.RelativePathCache.GetAssetName(relativePath) : $"temp/{Guid.NewGuid():N}"; - return new AssetDataForObject(this.ContentCore.GetLocale(), this.ContentCore.ParseAssetName(relativePath, allowLocales: false), data, key => this.ContentCore.ParseAssetName(key, allowLocales: false).Name); + return new AssetDataForObject( + locale: this.ContentCore.GetLocale(), + assetName: this.ContentCore.ParseAssetName(relativePath, allowLocales: false), + data: data, + getNormalizedPath: key => this.ContentCore.ParseAssetName(key, allowLocales: false).Name, + reflection: this.Reflection + ); } } } diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 1a58d84b..364a7632 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -1754,8 +1754,8 @@ namespace StardewModdingAPI.Framework IManifest manifest = mod.Manifest; IMonitor monitor = this.LogManager.GetMonitor(mod.DisplayName); CaseInsensitivePathCache relativePathCache = this.ContentCore.GetCaseInsensitivePathCache(mod.DirectoryPath); - GameContentHelper gameContentHelper = new(this.ContentCore, manifest.UniqueID, mod.DisplayName, monitor); - IModContentHelper modContentHelper = new ModContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), relativePathCache); + GameContentHelper gameContentHelper = new(this.ContentCore, manifest.UniqueID, mod.DisplayName, monitor, this.Reflection); + IModContentHelper modContentHelper = new ModContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), relativePathCache, this.Reflection); TranslationHelper translationHelper = new(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, modContentHelper, translationHelper, jsonHelper, relativePathCache); mod.SetMod(contentPack, monitor, translationHelper); @@ -1838,8 +1838,8 @@ namespace StardewModdingAPI.Framework CaseInsensitivePathCache relativePathCache = this.ContentCore.GetCaseInsensitivePathCache(packDirPath); - GameContentHelper gameContentHelper = new(contentCore, packManifest.UniqueID, packManifest.Name, packMonitor); - IModContentHelper packContentHelper = new ModContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, gameContentHelper.GetUnderlyingContentManager(), relativePathCache); + GameContentHelper gameContentHelper = new(contentCore, packManifest.UniqueID, packManifest.Name, packMonitor, this.Reflection); + IModContentHelper packContentHelper = new ModContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, gameContentHelper.GetUnderlyingContentManager(), relativePathCache, this.Reflection); TranslationHelper packTranslationHelper = new(packManifest.UniqueID, contentCore.GetLocale(), contentCore.Language); ContentPack contentPack = new(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper, relativePathCache); @@ -1852,10 +1852,10 @@ namespace StardewModdingAPI.Framework ICommandHelper commandHelper = new CommandHelper(mod, this.CommandManager); CaseInsensitivePathCache relativePathCache = this.ContentCore.GetCaseInsensitivePathCache(mod.DirectoryPath); #pragma warning disable CS0612 // deprecated code - ContentHelper contentHelper = new(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); + ContentHelper contentHelper = new(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor, this.Reflection); #pragma warning restore CS0612 - GameContentHelper gameContentHelper = new(contentCore, manifest.UniqueID, mod.DisplayName, monitor); - IModContentHelper modContentHelper = new ModContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), relativePathCache); + GameContentHelper gameContentHelper = new(contentCore, manifest.UniqueID, mod.DisplayName, monitor, this.Reflection); + IModContentHelper modContentHelper = new ModContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), relativePathCache, this.Reflection); IContentPackHelper contentPackHelper = new ContentPackHelper(manifest.UniqueID, new Lazy(GetContentPacks), CreateFakeContentPack); IDataHelper dataHelper = new DataHelper(manifest.UniqueID, mod.DirectoryPath, jsonHelper); IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection); diff --git a/src/SMAPI/IAssetDataForImage.cs b/src/SMAPI/IAssetDataForImage.cs index 388caa68..1416592e 100644 --- a/src/SMAPI/IAssetDataForImage.cs +++ b/src/SMAPI/IAssetDataForImage.cs @@ -23,8 +23,8 @@ namespace StardewModdingAPI void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace); /// Extend the image if needed to fit the given size. Note that this is an expensive operation, creates a new texture instance, and that extending a spritesheet horizontally may cause game errors or bugs. - /// The minimum texture width. - /// The minimum texture height. + /// The minimum texture width in pixels. + /// The minimum texture height in pixels. /// Whether the texture was resized. bool ExtendImage(int minWidth, int minHeight); } diff --git a/src/SMAPI/IAssetDataForMap.cs b/src/SMAPI/IAssetDataForMap.cs index 89ee28f2..0b637baf 100644 --- a/src/SMAPI/IAssetDataForMap.cs +++ b/src/SMAPI/IAssetDataForMap.cs @@ -17,5 +17,12 @@ namespace StardewModdingAPI /// The tile area within the target map to overwrite, or null to patch the whole map. The original content within this area will be erased. This must be within the bounds of the existing map. /// Indicates how the map should be patched. void PatchMap(Map source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMapMode patchMode = PatchMapMode.Overlay); + + /// Extend the map if needed to fit the given size. Note that this is an expensive operation and resizes the map in-place. + /// The map to resize. + /// The minimum map width in tiles. + /// The minimum map height in tiles. + /// Whether the map was resized. + bool ExtendMap(Map map, int minWidth, int minHeight); } } -- cgit From a21d24f4b7d14701205a6805422de31da84da6ca Mon Sep 17 00:00:00 2001 From: Khloe Leclair Date: Tue, 12 Apr 2022 02:07:21 -0400 Subject: Replace bitfields for state and just use comma-separated strings. Add a note that numbers may be inaccurate if filtering is used when sections are collapsed. Add quick navigation links. --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 13 ++ src/SMAPI.Web/wwwroot/Content/css/main.css | 32 ++++ src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 235 +++++++++---------------- 3 files changed, 124 insertions(+), 156 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 7aa0fd6b..d95499b7 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -94,6 +94,19 @@ } +@* quick navigation links *@ +@if (log != null) +{ + +} + @* upload result banner *@ @if (Model.UploadError != null) { diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css index dcc7a798..52b304d0 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/main.css +++ b/src/SMAPI.Web/wwwroot/Content/css/main.css @@ -72,6 +72,7 @@ a { color: #666; } +#quickNav h4, #sidebar h4 { margin: 1.5em 0 0.2em 0; width: 10em; @@ -80,11 +81,13 @@ a { font-weight: normal; } +#quickNav a, #sidebar a { color: #77B; border: 0; } +#quickNav ul, #quickNav li, #sidebar ul, #sidebar li { margin: 0; padding: 0; @@ -93,10 +96,29 @@ a { color: #888; } +#quickNav li, #sidebar li { margin-left: 1em; } +/* quick navigation */ + +#quickNav { + position: fixed; + left: 8px; + bottom: 3em; + width: 12em; + color: #666; +} + +@media (max-height: 400px) { + #quickNav { + position: unset; + width: auto; + } +} + + /* footer */ #footer { margin: 1em; @@ -111,11 +133,16 @@ a { /* mobile fixes */ @media (min-width: 1020px) and (max-width: 1199px) { + #quickNav, #sidebar { width: 7em; background: none; } + #quickNav h4 { + width: unset; + } + #content-column { left: 7em; } @@ -138,4 +165,9 @@ a { top: inherit; left: inherit; } + + #quickNav { + position: unset; + width: auto; + } } diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 8538423f..37c57082 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -97,120 +97,23 @@ smapi.logParser = function (state) { }, /** - * Convert an array of boolean values into a bitmap. - * @param {Boolean[]} value An array of boolean values - * @returns {BigInt} + * Try parsing the value as an integer, in base 10. Return the number + * if it's valid, or return the default value otherwise. + * @param {String} value The value to parse. + * @param {Number} defaultValue The value to return if parsing fails. + * @param {Function} critera An optional criteria to check the number with. */ - toBitmap(value) { - let result = BigInt(0); - if (!Array.isArray(value)) - return value ? BigInt(1) : BigInt(0); - - for (let i = 0; i < value.length; i++) { - if (value[i]) - result += BigInt(2) ** BigInt(value.length - i - 1); + tryNumber(value, defaultValue, critera = null) { + try { + value = parseInt(value, 10); + } catch { + return defaultValue; } - return result; - }, - - /** - * Convert a bitmap into an array of boolean values. - * @param {BigInt} value The bitmap - * @param {Number} length The expected length of the result - * @returns {Boolean[]} - */ - fromBitmap(value, length = -1) { - if (typeof value != "bigint") - value = ""; - else - value = value.toString(2); - - const result = []; - while (length > value.length) { - result.push(false); - length--; - } - - for (let i = 0; i < value.length; i++) { - result.push(value[i] === "1" ? true : false); - } - - return result; - }, - - /** - * Convert a base-64 string to a BigInt. - * @param {string} value - * @returns {BigInt} - */ - b64ToBigInt(value) { - const bin = atob(value); - const hex = []; - - for (let i = 0; i < bin.length; i++) { - let h = bin.charCodeAt(i).toString(16); - if (h.length % 2) h = `0${h}`; - hex.push(h); - } - - return BigInt(`0x${hex.join('')}`); - }, - - /** - * Convert a BigInt to a base-64 string. - * @param {BigInt} value - * @returns {string} - */ - bigIntTo64(value) { - let hex = value.toString(16); - if (hex.length % 2) hex = `0${hex}`; - - const result = []; - for (let i = 0; i < hex.length; i += 2) { - const val = parseInt(hex.slice(i, i + 2), 16); - result.push(String.fromCharCode(val)); - } - - return btoa(result.join('')); - }, - - /** - * Make a base-64 string URL safe. - * @param {string} value - * @returns {string} - */ - b64ToUrl(value) { - return value.replace(/\//g, '_').replace(/=/g, '-').replace(/\+/g, '.'); - }, - - /** - * Convert a URL safe base-64 string back to normal. - * @param {string} value - * @returns {string} - */ - urlTob64(value) { - return value.replace(/_/g, '/').replace(/-/g, '=').replace(/\./g, '+'); - }, + if (isNaN(value) || !isFinite(value) || (critera && !critera(value))) + return defaultValue; - /** - * Convert an array of booleans to a BigInt bitmap, then convert that - * to a base-64 string, then make it URL safe. - * @param {Boolean[]} value - * @returns {string} - */ - toUrlBitmap(value) { - return helpers.b64ToUrl(helpers.bigIntTo64(helpers.toBitmap(value))); - }, - - /** - * Convert a URL safe base-64 string to a normal base-64 string, convert - * that to a BigInt, and then parse a bitmap from the BigInt. - * @param {string} value - * @param {Number} length The expected length of the bitmap. - */ - fromUrlBitmap(value, length = -1) { - return helpers.fromBitmap(helpers.b64ToBigInt(helpers.urlTob64(value)), length); + return value; }, /** @@ -433,12 +336,18 @@ smapi.logParser = function (state) { "div", { class: "stats" }, [ - "showing ", - createElement("strong", helpers.formatNumber(props.start + 1)), - " to ", - createElement("strong", helpers.formatNumber(props.end)), - " of ", - createElement("strong", helpers.formatNumber(props.filtered)), + createElement('abbr', { + attrs: { + title: "These numbers may be inaccurate when using filtering with sections collapsed." + } + }, [ + "showing ", + createElement("strong", helpers.formatNumber(props.start + 1)), + " to ", + createElement("strong", helpers.formatNumber(props.end)), + " of ", + createElement("strong", helpers.formatNumber(props.filtered)) + ]), " (total: ", createElement("strong", helpers.formatNumber(props.total)), ")" @@ -788,64 +697,66 @@ smapi.logParser = function (state) { loadFromUrl: function () { const params = new URL(location).searchParams; if (params.has("PerPage")) { - const perPage = parseInt(params.get("PerPage")); - if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) - state.perPage = perPage; + state.perPage = helpers.tryNumber(params.get("PerPage"), 1000, n => n > 0); + } else { + state.perPage = 1000; } if (params.has("Page")) { - const page = parseInt(params.get("Page")); - if (!isNaN(page) && isFinite(page) && page > 0) - this.page = page; + this.page = helpers.tryNumber(params.get("Page"), 1, n => n > 0); + } else { + this.page = 1; } - let updateFilter = false; - - if (params.has("Filter")) { + if (params.has("Filter")) state.filterText = params.get("Filter"); - updateFilter = true; - } + else + state.filterText = ""; if (params.has("FilterMode")) { - const values = helpers.fromUrlBitmap(params.get("FilterMode"), 3); - state.useRegex = values[0]; - state.useInsensitive = values[1]; - state.useWord = values[2]; - updateFilter = true; + const values = params.get("FilterMode").split("~"); + state.useRegex = values.includes('Regex'); + state.useInsensitive = !values.includes('Sensitive'); + state.useWord = values.includes('Word'); + } else { + state.useRegex = false; + state.useInsensitive = true; + state.useWord = false; } if (params.has("Mods")) { - const keys = Object.keys(this.showMods); - const value = params.get("Mods"); - const values = value === "all" ? true : value === "none" ? false : helpers.fromUrlBitmap(value, keys.length); - - for (let i = 0; i < keys.length; i++) { - this.showMods[keys[i]] = Array.isArray(values) ? values[i] : values; - } + const value = params.get("Mods").split("~"); + for (const key of Object.keys(this.showMods)) + this.showMods[key] = value.includes(key); - updateModFilters(); + } else { + for (const key of Object.keys(this.showMods)) + this.showMods[key] = state.defaultMods[key]; } if (params.has("Levels")) { - const keys = Object.keys(this.showLevels); - const values = helpers.fromUrlBitmap(params.get("Levels"), keys.length); + const values = params.get("Levels").split("~"); + for (const key of Object.keys(this.showLevels)) + this.showLevels[key] = values.includes(key); - for (let i = 0; i < keys.length; i++) { - this.showLevels[keys[i]] = values[i]; - } + } else { + const keys = Object.keys(this.showLevels); + for (const key of Object.keys(this.showLevels)) + this.showLevels[key] = state.defaultLevels[key]; } if (params.has("Sections")) { - const keys = Object.keys(this.showSections); - const values = helpers.fromUrlBitmap(params.get("Levels"), keys.length); + const values = params.get("Sections").split("~"); + for (const key of Object.keys(this.showSections)) + this.showSections[key] = values.includes(key); - for (let i = 0; i < keys.length; i++) { - this.showSections[keys[i]] = values[i]; - } + } else { + for (const key of Object.keys(this.showSections)) + this.showSections[key] = state.defaultSections[key]; } - if (updateFilter) - this.updateFilterText(); + updateModFilters(); + this.updateFilterText(); }, // Whenever the page state changed, replace the current page URL. Using @@ -858,23 +769,35 @@ smapi.logParser = function (state) { url.searchParams.set("PerPage", state.perPage); if (!helpers.shallowEquals(this.showMods, state.defaultMods)) - url.searchParams.set("Mods", stats.modsHidden == 0 ? "all" : stats.modsShown == 0 ? "none" : helpers.toUrlBitmap(Object.values(this.showMods))); + url.searchParams.set("Mods", Object.entries(this.showMods).filter(x => x[1]).map(x => x[0]).join("~")); else url.searchParams.delete("Mods"); if (!helpers.shallowEquals(this.showLevels, state.defaultLevels)) - url.searchParams.set("Levels", helpers.toUrlBitmap(Object.values(this.showLevels))); + url.searchParams.set("Levels", Object.entries(this.showLevels).filter(x => x[1]).map(x => x[0]).join("~")); else url.searchParams.delete("Levels"); if (!helpers.shallowEquals(this.showSections, state.defaultSections)) - url.searchParams.set("Sections", helpers.toUrlBitmap(Object.values(this.showSections))); + url.searchParams.set("Sections", Object.entries(this.showSections).filter(x => x[1]).map(x => x[0]).join("~")); else url.searchParams.delete("Sections"); if (state.filterText && state.filterText.length) { url.searchParams.set("Filter", state.filterText); - url.searchParams.set("FilterMode", helpers.toUrlBitmap([state.useRegex, state.useInsensitive, state.useWord])); + const modes = []; + if (state.useRegex) + modes.push("Regex"); + if (!state.useInsensitive) + modes.push("Sensitive"); + if (state.useWord) + modes.push("Word"); + + if (modes.length) + url.searchParams.set("FilterMode", modes.join("~")); + else + url.searchParams.delete("FilterMode"); + } else { url.searchParams.delete("Filter"); url.searchParams.delete("FilterMode"); -- cgit From 0b9227564979b3e6e71dbd48ced2a9b7407fd640 Mon Sep 17 00:00:00 2001 From: Khloe Leclair Date: Tue, 12 Apr 2022 02:18:51 -0400 Subject: Make horizontal scrolling with the quick navigation links less bad. Probably need to move them into the actual sidebar element though for proper sorting. --- src/SMAPI.Web/wwwroot/Content/css/log-parser.css | 3 +++ 1 file changed, 3 insertions(+) (limited to 'src') diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index 41b54e11..e47a938d 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -13,6 +13,9 @@ caption { #output { padding: 10px; overflow: auto; + z-index: 1; + background: #fff; + position: relative; } #output h2 { -- cgit From 4f54f517ce3d6fd6e87cfee6b0ce61346d62c3e3 Mon Sep 17 00:00:00 2001 From: Khloe Leclair Date: Tue, 12 Apr 2022 13:50:51 -0400 Subject: Use an optional section for rendering quick navigation links on the mod viewer, containing them within the #sidebar element. --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 22 ++++++++++++---------- src/SMAPI.Web/Views/Shared/_Layout.cshtml | 2 ++ src/SMAPI.Web/wwwroot/Content/css/log-parser.css | 3 --- src/SMAPI.Web/wwwroot/Content/css/main.css | 6 ------ 4 files changed, 14 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index d95499b7..d55bfd4d 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -95,16 +95,18 @@ } @* quick navigation links *@ -@if (log != null) -{ - +@section SidebarExtra { + @if (log != null) + { + + } } @* upload result banner *@ diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 7c86a68c..248cc7ef 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -30,6 +30,8 @@
  • Log parser
  • JSON validator
  • + + @RenderSection("SidebarExtra", required: false)
    diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index e47a938d..41b54e11 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -13,9 +13,6 @@ caption { #output { padding: 10px; overflow: auto; - z-index: 1; - background: #fff; - position: relative; } #output h2 { diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css index 52b304d0..a0a407d8 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/main.css +++ b/src/SMAPI.Web/wwwroot/Content/css/main.css @@ -72,7 +72,6 @@ a { color: #666; } -#quickNav h4, #sidebar h4 { margin: 1.5em 0 0.2em 0; width: 10em; @@ -81,13 +80,11 @@ a { font-weight: normal; } -#quickNav a, #sidebar a { color: #77B; border: 0; } -#quickNav ul, #quickNav li, #sidebar ul, #sidebar li { margin: 0; padding: 0; @@ -96,7 +93,6 @@ a { color: #888; } -#quickNav li, #sidebar li { margin-left: 1em; } @@ -105,10 +101,8 @@ a { #quickNav { position: fixed; - left: 8px; bottom: 3em; width: 12em; - color: #666; } @media (max-height: 400px) { -- cgit From 0b48c1748b354458059c7607415288de072b01e9 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Tue, 12 Apr 2022 19:15:39 -0400 Subject: enable nullable annotations in the web project & related code (#837) --- build/common.targets | 4 +- .../ISemanticVersion.cs | 4 + .../Framework/Clients/WebApi/ModEntryModel.cs | 19 ++- .../Clients/WebApi/ModEntryVersionModel.cs | 9 +- .../Clients/WebApi/ModExtendedMetadataModel.cs | 36 +++-- .../Clients/WebApi/ModSearchEntryModel.cs | 27 ++-- .../Framework/Clients/WebApi/ModSearchModel.cs | 10 +- .../Framework/Clients/WebApi/WebApiClient.cs | 5 +- .../Framework/Clients/Wiki/ChangeDescriptor.cs | 12 +- .../Framework/Clients/Wiki/WikiClient.cs | 159 +++++++++++---------- .../Clients/Wiki/WikiCompatibilityInfo.cs | 33 +++-- .../Clients/Wiki/WikiCompatibilityStatus.cs | 2 - .../Framework/Clients/Wiki/WikiModEntry.cs | 96 ++++++++++--- .../Framework/Clients/Wiki/WikiModList.cs | 23 ++- src/SMAPI.Toolkit/SemanticVersion.cs | 3 + src/SMAPI.Toolkit/SemanticVersionComparer.cs | 2 +- src/SMAPI.Web/BackgroundService.cs | 24 +++- src/SMAPI.Web/Controllers/IndexController.cs | 12 +- .../Controllers/JsonValidatorController.cs | 46 +++--- src/SMAPI.Web/Controllers/LogParserController.cs | 6 +- src/SMAPI.Web/Controllers/ModsApiController.cs | 70 ++++----- src/SMAPI.Web/Controllers/ModsController.cs | 7 +- .../Framework/AllowLargePostsAttribute.cs | 4 +- src/SMAPI.Web/Framework/Caching/Cached.cs | 11 +- .../Framework/Caching/Mods/IModCacheRepository.cs | 5 +- .../Caching/Mods/ModCacheMemoryRepository.cs | 5 +- .../Framework/Caching/Wiki/IWikiCacheRepository.cs | 9 +- .../Caching/Wiki/WikiCacheMemoryRepository.cs | 11 +- .../Framework/Caching/Wiki/WikiMetadata.cs | 11 +- .../Clients/Chucklefish/ChucklefishClient.cs | 12 +- .../Clients/CurseForge/CurseForgeClient.cs | 14 +- .../CurseForge/ResponseModels/ModFileModel.cs | 22 ++- .../Clients/CurseForge/ResponseModels/ModModel.cs | 30 +++- .../Framework/Clients/GenericModDownload.cs | 13 +- src/SMAPI.Web/Framework/Clients/GenericModPage.cs | 23 +-- src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs | 26 +++- .../Framework/Clients/GitHub/GitHubClient.cs | 30 ++-- .../Framework/Clients/GitHub/GitLicense.cs | 26 +++- .../Framework/Clients/GitHub/GitRelease.cs | 36 +++-- src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs | 26 +++- .../Framework/Clients/GitHub/IGitHubClient.cs | 6 +- src/SMAPI.Web/Framework/Clients/IModSiteClient.cs | 4 +- .../Framework/Clients/ModDrop/ModDropClient.cs | 20 +-- .../ModDrop/ResponseModels/FileDataModel.cs | 42 ++++-- .../Clients/ModDrop/ResponseModels/ModDataModel.cs | 24 +++- .../Clients/ModDrop/ResponseModels/ModListModel.cs | 7 +- .../Clients/ModDrop/ResponseModels/ModModel.cs | 22 ++- .../Framework/Clients/Nexus/NexusClient.cs | 63 ++++---- .../Clients/Nexus/ResponseModels/NexusMod.cs | 43 ++++-- .../Framework/Clients/Pastebin/IPastebinClient.cs | 2 - .../Framework/Clients/Pastebin/PasteInfo.cs | 28 +++- .../Framework/Clients/Pastebin/PastebinClient.cs | 14 +- src/SMAPI.Web/Framework/Compression/GzipHelper.cs | 9 +- src/SMAPI.Web/Framework/Compression/IGzipHelper.cs | 5 +- .../Framework/ConfigModels/ApiClientsConfig.cs | 36 +++-- .../Framework/ConfigModels/ModOverrideConfig.cs | 6 +- .../Framework/ConfigModels/ModUpdateCheckConfig.cs | 10 +- src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs | 6 +- .../Framework/ConfigModels/SmapiInfoConfig.cs | 8 +- src/SMAPI.Web/Framework/Extensions.cs | 8 +- src/SMAPI.Web/Framework/IModDownload.cs | 9 +- src/SMAPI.Web/Framework/IModPage.cs | 18 ++- .../Framework/InternalControllerFeatureProvider.cs | 2 - .../Framework/JobDashboardAuthorizationFilter.cs | 2 - src/SMAPI.Web/Framework/ModInfoModel.cs | 33 +++-- src/SMAPI.Web/Framework/ModSiteManager.cs | 58 ++++---- .../RedirectRules/RedirectHostsToUrlsRule.cs | 10 +- .../Framework/RedirectRules/RedirectMatchRule.cs | 6 +- .../RedirectRules/RedirectPathsToUrlsRule.cs | 6 +- .../Framework/RedirectRules/RedirectToHttpsRule.cs | 6 +- .../Framework/Storage/IStorageProvider.cs | 2 - src/SMAPI.Web/Framework/Storage/StorageProvider.cs | 52 +++---- src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs | 41 +++++- src/SMAPI.Web/Framework/Storage/UploadResult.cs | 14 +- src/SMAPI.Web/Framework/VersionConstraint.cs | 6 +- src/SMAPI.Web/Program.cs | 2 - src/SMAPI.Web/Startup.cs | 4 +- src/SMAPI.Web/ViewModels/IndexModel.cs | 13 +- src/SMAPI.Web/ViewModels/IndexVersionModel.cs | 15 +- .../JsonValidator/JsonValidatorErrorModel.cs | 15 +- .../ViewModels/JsonValidator/JsonValidatorModel.cs | 29 ++-- .../JsonValidator/JsonValidatorRequestModel.cs | 19 ++- src/SMAPI.Web/ViewModels/LogParserModel.cs | 2 +- src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs | 27 +++- src/SMAPI.Web/ViewModels/ModLinkModel.cs | 6 +- src/SMAPI.Web/ViewModels/ModListModel.cs | 19 +-- src/SMAPI.Web/ViewModels/ModModel.cs | 65 ++++++--- src/SMAPI.Web/Views/Index/Index.cshtml | 6 +- src/SMAPI.Web/Views/Index/Privacy.cshtml | 4 - src/SMAPI.Web/Views/JsonValidator/Index.cshtml | 12 +- src/SMAPI.Web/Views/LogParser/Index.cshtml | 2 +- src/SMAPI.Web/Views/Mods/Index.cshtml | 6 +- src/SMAPI.Web/Views/Shared/_Layout.cshtml | 4 - src/SMAPI.Web/Views/_ViewStart.cshtml | 4 - src/SMAPI.Web/appsettings.json | 3 +- src/SMAPI.sln.DotSettings | 1 + src/SMAPI/SemanticVersion.cs | 3 + 97 files changed, 1044 insertions(+), 768 deletions(-) (limited to 'src') diff --git a/build/common.targets b/build/common.targets index c227190a..c04546d0 100644 --- a/build/common.targets +++ b/build/common.targets @@ -7,8 +7,8 @@ $(AssemblySearchPaths);{GAC} - enable - $(NoWarn);CS8632 + enable + $(NoWarn);CS8632 $(DefineConstants);SMAPI_FOR_WINDOWS diff --git a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs index 7998272f..dc226b7c 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI { @@ -28,6 +29,9 @@ namespace StardewModdingAPI ** Accessors *********/ /// Whether this is a prerelease version. +#if NET5_0_OR_GREATER + [MemberNotNullWhen(true, nameof(ISemanticVersion.PrereleaseTag))] +#endif bool IsPrerelease(); /// Get whether this version is older than the specified version. diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs index d5ca2034..4fc4ea54 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi @@ -11,15 +9,26 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi ** Accessors *********/ /// The mod's unique ID (if known). - public string ID { get; set; } + public string ID { get; } /// The update version recommended by the web API based on its version update and mapping rules. - public ModEntryVersionModel SuggestedUpdate { get; set; } + public ModEntryVersionModel? SuggestedUpdate { get; set; } /// Optional extended data which isn't needed for update checks. - public ModExtendedMetadataModel Metadata { get; set; } + public ModExtendedMetadataModel? Metadata { get; set; } /// The errors that occurred while fetching update data. public string[] Errors { get; set; } = Array.Empty(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's unique ID (if known). + public ModEntryModel(string id) + { + this.ID = id; + } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs index 9aac7fd3..a1e78986 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs @@ -1,5 +1,3 @@ -#nullable disable - using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Serialization.Converters; @@ -13,18 +11,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi *********/ /// The version number. [JsonConverter(typeof(NonStandardSemanticVersionConverter))] - public ISemanticVersion Version { get; set; } + public ISemanticVersion Version { get; } /// The mod page URL. - public string Url { get; set; } + public string Url { get; } /********* ** Public methods *********/ - /// Construct an instance. - public ModEntryVersionModel() { } - /// Construct an instance. /// The version number. /// The mod page URL. diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs index eb54ec78..272a2063 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -23,7 +21,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public string[] ID { get; set; } = Array.Empty(); /// The mod's display name. - public string Name { get; set; } + public string? Name { get; set; } /// The mod ID on Nexus. public int? NexusID { get; set; } @@ -35,31 +33,31 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public int? CurseForgeID { get; set; } /// The mod key in the CurseForge mod repo (used in mod page URLs). - public string CurseForgeKey { get; set; } + public string? CurseForgeKey { get; set; } /// The mod ID in the ModDrop mod repo. public int? ModDropID { get; set; } /// The GitHub repository in the form 'owner/repo'. - public string GitHubRepo { get; set; } + public string? GitHubRepo { get; set; } /// The URL to a non-GitHub source repo. - public string CustomSourceUrl { get; set; } + public string? CustomSourceUrl { get; set; } /// The custom mod page URL (if applicable). - public string CustomUrl { get; set; } + public string? CustomUrl { get; set; } /// The main version. - public ModEntryVersionModel Main { get; set; } + public ModEntryVersionModel? Main { get; set; } /// The latest optional version, if newer than . - public ModEntryVersionModel Optional { get; set; } + public ModEntryVersionModel? Optional { get; set; } /// The latest unofficial version, if newer than and . - public ModEntryVersionModel Unofficial { get; set; } + public ModEntryVersionModel? Unofficial { get; set; } /// The latest unofficial version for the current Stardew Valley or SMAPI beta, if any. - public ModEntryVersionModel UnofficialForBeta { get; set; } + public ModEntryVersionModel? UnofficialForBeta { get; set; } /**** ** Stable compatibility @@ -69,10 +67,10 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public WikiCompatibilityStatus? CompatibilityStatus { get; set; } /// The human-readable summary of the compatibility status or workaround, without HTML formatting. - public string CompatibilitySummary { get; set; } + public string? CompatibilitySummary { get; set; } /// The game or SMAPI version which broke this mod, if applicable. - public string BrokeIn { get; set; } + public string? BrokeIn { get; set; } /**** ** Beta compatibility @@ -82,22 +80,22 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public WikiCompatibilityStatus? BetaCompatibilityStatus { get; set; } /// The human-readable summary of the compatibility status or workaround for the Stardew Valley beta (if any), without HTML formatting. - public string BetaCompatibilitySummary { get; set; } + public string? BetaCompatibilitySummary { get; set; } /// The beta game or SMAPI version which broke this mod, if applicable. - public string BetaBrokeIn { get; set; } + public string? BetaBrokeIn { get; set; } /**** ** Version mappings ****/ /// A serialized change descriptor to apply to the local version during update checks (see ). - public string ChangeLocalVersions { get; set; } + public string? ChangeLocalVersions { get; set; } /// A serialized change descriptor to apply to the remote version during update checks (see ). - public string ChangeRemoteVersions { get; set; } + public string? ChangeRemoteVersions { get; set; } /// A serialized change descriptor to apply to the update keys during update checks (see ). - public string ChangeUpdateKeys { get; set; } + public string? ChangeUpdateKeys { get; set; } /********* @@ -113,7 +111,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// The latest optional version, if newer than . /// The latest unofficial version, if newer than and . /// The latest unofficial version for the current Stardew Valley or SMAPI beta, if any. - public ModExtendedMetadataModel(WikiModEntry wiki, ModDataRecord db, ModEntryVersionModel main, ModEntryVersionModel optional, ModEntryVersionModel unofficial, ModEntryVersionModel unofficialForBeta) + public ModExtendedMetadataModel(WikiModEntry? wiki, ModDataRecord? db, ModEntryVersionModel? main, ModEntryVersionModel? optional, ModEntryVersionModel? unofficial, ModEntryVersionModel? unofficialForBeta) { // versions this.Main = main; diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs index 8fe8fa2a..9c11e1db 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs @@ -1,6 +1,5 @@ -#nullable disable - using System; +using System.Linq; namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi { @@ -11,37 +10,39 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi ** Accessors *********/ /// The unique mod ID. - public string ID { get; set; } + public string ID { get; } /// The namespaced mod update keys (if available). - public string[] UpdateKeys { get; set; } + public string[] UpdateKeys { get; private set; } /// The mod version installed by the local player. This is used for version mapping in some cases. - public ISemanticVersion InstalledVersion { get; set; } + public ISemanticVersion? InstalledVersion { get; } /// Whether the installed version is broken or could not be loaded. - public bool IsBroken { get; set; } + public bool IsBroken { get; } /********* ** Public methods *********/ - /// Construct an empty instance. - public ModSearchEntryModel() - { - // needed for JSON deserializing - } - /// Construct an instance. /// The unique mod ID. /// The version installed by the local player. This is used for version mapping in some cases. /// The namespaced mod update keys (if available). /// Whether the installed version is broken or could not be loaded. - public ModSearchEntryModel(string id, ISemanticVersion installedVersion, string[] updateKeys, bool isBroken = false) + public ModSearchEntryModel(string id, ISemanticVersion? installedVersion, string[]? updateKeys, bool isBroken = false) { this.ID = id; this.InstalledVersion = installedVersion; this.UpdateKeys = updateKeys ?? Array.Empty(); + this.IsBroken = isBroken; + } + + /// Add update keys for the mod. + /// The update keys to add. + public void AddUpdateKeys(params string[] updateKeys) + { + this.UpdateKeys = this.UpdateKeys.Concat(updateKeys).ToArray(); } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs index 393391f7..a0cd9d4d 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Linq; using StardewModdingAPI.Toolkit.Utilities; @@ -24,18 +22,12 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public ISemanticVersion GameVersion { get; set; } /// The OS on which the player plays. - public Platform? Platform { get; set; } + public Platform Platform { get; set; } /********* ** Public methods *********/ - /// Construct an empty instance. - public ModSearchModel() - { - // needed for JSON deserializing - } - /// Construct an instance. /// The mods to search. /// The SMAPI version installed by the player. If this is null, the API won't provide a recommended update. diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs index 56acb768..d4282617 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -72,7 +70,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi client.Headers["Content-Type"] = "application/json"; client.Headers["User-Agent"] = $"SMAPI/{this.Version}"; string response = client.UploadString(fullUrl, data); - return JsonConvert.DeserializeObject(response, this.JsonSettings); + return JsonConvert.DeserializeObject(response, this.JsonSettings) + ?? throw new InvalidOperationException($"Could not parse the response from POST {url}."); } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs index 910bf793..5978803e 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -49,7 +47,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// Apply the change descriptors to a comma-delimited field. /// The raw field text. /// Returns the modified field. - public string ApplyToCopy(string rawField) + public string? ApplyToCopy(string? rawField) { // get list List values = !string.IsNullOrWhiteSpace(rawField) @@ -75,12 +73,12 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { for (int i = values.Count - 1; i >= 0; i--) { - string value = this.FormatValue(values[i]?.Trim() ?? string.Empty); + string value = this.FormatValue(values[i].Trim()); if (this.Remove.Contains(value)) values.RemoveAt(i); - else if (this.Replace.TryGetValue(value, out string newValue)) + else if (this.Replace.TryGetValue(value, out string? newValue)) values[i] = newValue; } } @@ -88,7 +86,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki // add values if (this.Add.Any()) { - HashSet curValues = new HashSet(values.Select(p => p?.Trim() ?? string.Empty), StringComparer.OrdinalIgnoreCase); + HashSet curValues = new HashSet(values.Select(p => p.Trim()), StringComparer.OrdinalIgnoreCase); foreach (string add in this.Add) { if (!curValues.Contains(add)) @@ -121,7 +119,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// The raw change descriptor. /// The human-readable error message describing any invalid values that were ignored. /// Format a raw value into a normalized form if needed. - public static ChangeDescriptor Parse(string descriptor, out string[] errors, Func formatValue = null) + public static ChangeDescriptor Parse(string descriptor, out string[] errors, Func? formatValue = null) { // init formatValue ??= p => p; diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs index 86c3bd75..7f06d170 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; @@ -53,8 +51,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki doc.LoadHtml(html); // fetch game versions - string stableVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-stable-version']")?.InnerText; - string betaVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-beta-version']")?.InnerText; + string? stableVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-stable-version']")?.InnerText; + string? betaVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-beta-version']")?.InnerText; if (betaVersion == stableVersion) betaVersion = null; @@ -65,7 +63,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki if (modNodes == null) throw new InvalidOperationException("Can't parse wiki compatibility list, no mod data overrides section found."); - foreach (var entry in this.ParseOverrideEntries(modNodes)) + foreach (WikiDataOverrideEntry entry in this.ParseOverrideEntries(modNodes)) { if (entry.Ids.Any() != true || !entry.HasChanges) continue; @@ -85,18 +83,17 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki } // build model - return new WikiModList - { - StableVersion = stableVersion, - BetaVersion = betaVersion, - Mods = mods - }; + return new WikiModList( + stableVersion: stableVersion, + betaVersion: betaVersion, + mods: mods + ); } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } @@ -118,71 +115,68 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki int? nexusID = this.GetAttributeAsNullableInt(node, "data-nexus-id"); int? chucklefishID = this.GetAttributeAsNullableInt(node, "data-cf-id"); int? curseForgeID = this.GetAttributeAsNullableInt(node, "data-curseforge-id"); - string curseForgeKey = this.GetAttribute(node, "data-curseforge-key"); + string? curseForgeKey = this.GetAttribute(node, "data-curseforge-key"); int? modDropID = this.GetAttributeAsNullableInt(node, "data-moddrop-id"); - string githubRepo = this.GetAttribute(node, "data-github"); - string customSourceUrl = this.GetAttribute(node, "data-custom-source"); - string customUrl = this.GetAttribute(node, "data-url"); - string anchor = this.GetAttribute(node, "id"); - string contentPackFor = this.GetAttribute(node, "data-content-pack-for"); - string devNote = this.GetAttribute(node, "data-dev-note"); - string pullRequestUrl = this.GetAttribute(node, "data-pr"); + string? githubRepo = this.GetAttribute(node, "data-github"); + string? customSourceUrl = this.GetAttribute(node, "data-custom-source"); + string? customUrl = this.GetAttribute(node, "data-url"); + string? anchor = this.GetAttribute(node, "id"); + string? contentPackFor = this.GetAttribute(node, "data-content-pack-for"); + string? devNote = this.GetAttribute(node, "data-dev-note"); + string? pullRequestUrl = this.GetAttribute(node, "data-pr"); // parse stable compatibility - WikiCompatibilityInfo compatibility = new() - { - Status = this.GetAttributeAsEnum(node, "data-status") ?? WikiCompatibilityStatus.Ok, - BrokeIn = this.GetAttribute(node, "data-broke-in"), - UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-unofficial-version"), - UnofficialUrl = this.GetAttribute(node, "data-unofficial-url"), - Summary = this.GetInnerHtml(node, "mod-summary")?.Trim() - }; + WikiCompatibilityInfo compatibility = new( + status: this.GetAttributeAsEnum(node, "data-status") ?? WikiCompatibilityStatus.Ok, + brokeIn: this.GetAttribute(node, "data-broke-in"), + unofficialVersion: this.GetAttributeAsSemanticVersion(node, "data-unofficial-version"), + unofficialUrl: this.GetAttribute(node, "data-unofficial-url"), + summary: this.GetInnerHtml(node, "mod-summary")?.Trim() + ); // parse beta compatibility - WikiCompatibilityInfo betaCompatibility = null; + WikiCompatibilityInfo? betaCompatibility = null; { WikiCompatibilityStatus? betaStatus = this.GetAttributeAsEnum(node, "data-beta-status"); if (betaStatus.HasValue) { - betaCompatibility = new WikiCompatibilityInfo - { - Status = betaStatus.Value, - BrokeIn = this.GetAttribute(node, "data-beta-broke-in"), - UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-beta-unofficial-version"), - UnofficialUrl = this.GetAttribute(node, "data-beta-unofficial-url"), - Summary = this.GetInnerHtml(node, "mod-beta-summary") - }; + betaCompatibility = new WikiCompatibilityInfo( + status: betaStatus.Value, + brokeIn: this.GetAttribute(node, "data-beta-broke-in"), + unofficialVersion: this.GetAttributeAsSemanticVersion(node, "data-beta-unofficial-version"), + unofficialUrl: this.GetAttribute(node, "data-beta-unofficial-url"), + summary: this.GetInnerHtml(node, "mod-beta-summary") + ); } } // find data overrides - WikiDataOverrideEntry overrides = ids + WikiDataOverrideEntry? overrides = ids .Select(id => overridesById.TryGetValue(id, out overrides) ? overrides : null) .FirstOrDefault(p => p != null); // yield model - yield return new WikiModEntry - { - ID = ids, - Name = names, - Author = authors, - NexusID = nexusID, - ChucklefishID = chucklefishID, - CurseForgeID = curseForgeID, - CurseForgeKey = curseForgeKey, - ModDropID = modDropID, - GitHubRepo = githubRepo, - CustomSourceUrl = customSourceUrl, - CustomUrl = customUrl, - ContentPackFor = contentPackFor, - Compatibility = compatibility, - BetaCompatibility = betaCompatibility, - Warnings = warnings, - PullRequestUrl = pullRequestUrl, - DevNote = devNote, - Overrides = overrides, - Anchor = anchor - }; + yield return new WikiModEntry( + id: ids, + name: names, + author: authors, + nexusId: nexusID, + chucklefishId: chucklefishID, + curseForgeId: curseForgeID, + curseForgeKey: curseForgeKey, + modDropId: modDropID, + githubRepo: githubRepo, + customSourceUrl: customSourceUrl, + customUrl: customUrl, + contentPackFor: contentPackFor, + compatibility: compatibility, + betaCompatibility: betaCompatibility, + warnings: warnings, + pullRequestUrl: pullRequestUrl, + devNote: devNote, + overrides: overrides, + anchor: anchor + ); } } @@ -196,10 +190,10 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { Ids = this.GetAttributeAsCsv(node, "data-id"), ChangeLocalVersions = this.GetAttributeAsChangeDescriptor(node, "data-local-version", - raw => SemanticVersion.TryParse(raw, out ISemanticVersion version) ? version.ToString() : raw + raw => SemanticVersion.TryParse(raw, out ISemanticVersion? version) ? version.ToString() : raw ), ChangeRemoteVersions = this.GetAttributeAsChangeDescriptor(node, "data-remote-version", - raw => SemanticVersion.TryParse(raw, out ISemanticVersion version) ? version.ToString() : raw + raw => SemanticVersion.TryParse(raw, out ISemanticVersion? version) ? version.ToString() : raw ), ChangeUpdateKeys = this.GetAttributeAsChangeDescriptor(node, "data-update-keys", @@ -212,7 +206,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// Get an attribute value. /// The element whose attributes to read. /// The attribute name. - private string GetAttribute(HtmlNode element, string name) + private string? GetAttribute(HtmlNode element, string name) { string value = element.GetAttributeValue(name, null); if (string.IsNullOrWhiteSpace(value)) @@ -225,9 +219,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// The element whose attributes to read. /// The attribute name. /// Format an raw entry value when applying changes. - private ChangeDescriptor GetAttributeAsChangeDescriptor(HtmlNode element, string name, Func formatValue) + private ChangeDescriptor? GetAttributeAsChangeDescriptor(HtmlNode element, string name, Func formatValue) { - string raw = this.GetAttribute(element, name); + string? raw = this.GetAttribute(element, name); return raw != null ? ChangeDescriptor.Parse(raw, out _, formatValue) : null; @@ -238,7 +232,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// The attribute name. private string[] GetAttributeAsCsv(HtmlNode element, string name) { - string raw = this.GetAttribute(element, name); + string? raw = this.GetAttribute(element, name); return !string.IsNullOrWhiteSpace(raw) ? raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() : Array.Empty(); @@ -250,7 +244,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// The attribute name. private TEnum? GetAttributeAsEnum(HtmlNode element, string name) where TEnum : struct { - string raw = this.GetAttribute(element, name); + string? raw = this.GetAttribute(element, name); if (raw == null) return null; if (!Enum.TryParse(raw, true, out TEnum value) && Enum.IsDefined(typeof(TEnum), value)) @@ -261,10 +255,10 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// Get an attribute value and parse it as a semantic version. /// The element whose attributes to read. /// The attribute name. - private ISemanticVersion GetAttributeAsSemanticVersion(HtmlNode element, string name) + private ISemanticVersion? GetAttributeAsSemanticVersion(HtmlNode element, string name) { - string raw = this.GetAttribute(element, name); - return SemanticVersion.TryParse(raw, out ISemanticVersion version) + string? raw = this.GetAttribute(element, name); + return SemanticVersion.TryParse(raw, out ISemanticVersion? version) ? version : null; } @@ -274,7 +268,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// The attribute name. private int? GetAttributeAsNullableInt(HtmlNode element, string name) { - string raw = this.GetAttribute(element, name); + string? raw = this.GetAttribute(element, name); if (raw != null && int.TryParse(raw, out int value)) return value; return null; @@ -283,7 +277,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// Get the text of an element with the given class name. /// The metadata container. /// The field name. - private string GetInnerHtml(HtmlNode container, string className) + private string? GetInnerHtml(HtmlNode container, string className) { return container.Descendants().FirstOrDefault(p => p.HasClass(className))?.InnerHtml; } @@ -293,8 +287,22 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] private class ResponseModel { + /********* + ** Accessors + *********/ /// The parse API results. - public ResponseParseModel Parse { get; set; } + public ResponseParseModel Parse { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The parse API results. + public ResponseModel(ResponseParseModel parse) + { + this.Parse = parse; + } } /// The inner response model for the MediaWiki parse API. @@ -303,8 +311,11 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] private class ResponseParseModel { + /********* + ** Accessors + *********/ /// The parsed text. - public IDictionary Text { get; set; } + public IDictionary Text { get; } = new Dictionary(); } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs index 30e76d04..71c90d0c 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { /// Compatibility info for a mod. @@ -9,18 +7,37 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki ** Accessors *********/ /// The compatibility status. - public WikiCompatibilityStatus Status { get; set; } + public WikiCompatibilityStatus Status { get; } /// The human-readable summary of the compatibility status or workaround, without HTML formatting. - public string Summary { get; set; } + public string? Summary { get; } - /// The game or SMAPI version which broke this mod (if applicable). - public string BrokeIn { get; set; } + /// The game or SMAPI version which broke this mod, if applicable. + public string? BrokeIn { get; } /// The version of the latest unofficial update, if applicable. - public ISemanticVersion UnofficialVersion { get; set; } + public ISemanticVersion? UnofficialVersion { get; } /// The URL to the latest unofficial update, if applicable. - public string UnofficialUrl { get; set; } + public string? UnofficialUrl { get; } + + + /********* + ** Accessors + *********/ + /// Construct an instance. + /// The compatibility status. + /// The human-readable summary of the compatibility status or workaround, without HTML formatting. + /// The game or SMAPI version which broke this mod, if applicable. + /// The version of the latest unofficial update, if applicable. + /// The URL to the latest unofficial update, if applicable. + public WikiCompatibilityInfo(WikiCompatibilityStatus status, string? summary, string? brokeIn, ISemanticVersion? unofficialVersion, string? unofficialUrl) + { + this.Status = status; + this.Summary = summary; + this.BrokeIn = brokeIn; + this.UnofficialVersion = unofficialVersion; + this.UnofficialUrl = unofficialUrl; + } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs index 2c222b71..5cdf489f 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { /// The compatibility status for a mod. diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs index 91943ff9..fc50125f 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -1,4 +1,4 @@ -#nullable disable +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { @@ -8,64 +8,114 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /********* ** Accessors *********/ - /// The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to newest order. - public string[] ID { get; set; } + /// The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to oldest order. + public string[] ID { get; } /// The mod's display name. If the mod has multiple names, the first one is the most canonical name. - public string[] Name { get; set; } + public string[] Name { get; } - /// The mod's author name. If the author has multiple names, the first one is the most canonical name. - public string[] Author { get; set; } + /// The mod's author name. If the author has multiple names, the first one is the most canonical name. + public string[] Author { get; } /// The mod ID on Nexus. - public int? NexusID { get; set; } + public int? NexusID { get; } /// The mod ID in the Chucklefish mod repo. - public int? ChucklefishID { get; set; } + public int? ChucklefishID { get; } /// The mod ID in the CurseForge mod repo. - public int? CurseForgeID { get; set; } + public int? CurseForgeID { get; } /// The mod key in the CurseForge mod repo (used in mod page URLs). - public string CurseForgeKey { get; set; } + public string? CurseForgeKey { get; } /// The mod ID in the ModDrop mod repo. - public int? ModDropID { get; set; } + public int? ModDropID { get; } /// The GitHub repository in the form 'owner/repo'. - public string GitHubRepo { get; set; } + public string? GitHubRepo { get; } /// The URL to a non-GitHub source repo. - public string CustomSourceUrl { get; set; } + public string? CustomSourceUrl { get; } /// The custom mod page URL (if applicable). - public string CustomUrl { get; set; } + public string? CustomUrl { get; } /// The name of the mod which loads this content pack, if applicable. - public string ContentPackFor { get; set; } + public string? ContentPackFor { get; } /// The mod's compatibility with the latest stable version of the game. - public WikiCompatibilityInfo Compatibility { get; set; } + public WikiCompatibilityInfo Compatibility { get; } /// The mod's compatibility with the latest beta version of the game (if any). - public WikiCompatibilityInfo BetaCompatibility { get; set; } + public WikiCompatibilityInfo? BetaCompatibility { get; } /// Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, should be used for beta versions of SMAPI instead of . +#if NET5_0_OR_GREATER + [MemberNotNullWhen(true, nameof(WikiModEntry.BetaCompatibility))] +#endif public bool HasBetaInfo => this.BetaCompatibility != null; /// The human-readable warnings for players about this mod. - public string[] Warnings { get; set; } + public string[] Warnings { get; } /// The URL of the pull request which submits changes for an unofficial update to the author, if any. - public string PullRequestUrl { get; set; } + public string? PullRequestUrl { get; } - /// Special notes intended for developers who maintain unofficial updates or submit pull requests. - public string DevNote { get; set; } + /// Special notes intended for developers who maintain unofficial updates or submit pull requests. + public string? DevNote { get; } /// The data overrides to apply to the mod's manifest or remote mod page data, if any. - public WikiDataOverrideEntry Overrides { get; set; } + public WikiDataOverrideEntry? Overrides { get; } /// The link anchor for the mod entry in the wiki compatibility list. - public string Anchor { get; set; } + public string? Anchor { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to oldest order. + /// The mod's display name. If the mod has multiple names, the first one is the most canonical name. + /// The mod's author name. If the author has multiple names, the first one is the most canonical name. + /// The mod ID on Nexus. + /// The mod ID in the Chucklefish mod repo. + /// The mod ID in the CurseForge mod repo. + /// The mod ID in the CurseForge mod repo. + /// The mod ID in the ModDrop mod repo. + /// The GitHub repository in the form 'owner/repo'. + /// The URL to a non-GitHub source repo. + /// The custom mod page URL (if applicable). + /// The name of the mod which loads this content pack, if applicable. + /// The mod's compatibility with the latest stable version of the game. + /// The mod's compatibility with the latest beta version of the game (if any). + /// The human-readable warnings for players about this mod. + /// The URL of the pull request which submits changes for an unofficial update to the author, if any. + /// Special notes intended for developers who maintain unofficial updates or submit pull requests. + /// The data overrides to apply to the mod's manifest or remote mod page data, if any. + /// The link anchor for the mod entry in the wiki compatibility list. + public WikiModEntry(string[] id, string[] name, string[] author, int? nexusId, int? chucklefishId, int? curseForgeId, string? curseForgeKey, int? modDropId, string? githubRepo, string? customSourceUrl, string? customUrl, string? contentPackFor, WikiCompatibilityInfo compatibility, WikiCompatibilityInfo? betaCompatibility, string[] warnings, string? pullRequestUrl, string? devNote, WikiDataOverrideEntry? overrides, string? anchor) + { + this.ID = id; + this.Name = name; + this.Author = author; + this.NexusID = nexusId; + this.ChucklefishID = chucklefishId; + this.CurseForgeID = curseForgeId; + this.CurseForgeKey = curseForgeKey; + this.ModDropID = modDropId; + this.GitHubRepo = githubRepo; + this.CustomSourceUrl = customSourceUrl; + this.CustomUrl = customUrl; + this.ContentPackFor = contentPackFor; + this.Compatibility = compatibility; + this.BetaCompatibility = betaCompatibility; + this.Warnings = warnings; + this.PullRequestUrl = pullRequestUrl; + this.DevNote = devNote; + this.Overrides = overrides; + this.Anchor = anchor; + } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs index 1787197a..24548078 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { /// Metadata from the wiki's mod compatibility list. @@ -9,12 +7,27 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki ** Accessors *********/ /// The stable game version. - public string StableVersion { get; set; } + public string? StableVersion { get; } /// The beta game version (if any). - public string BetaVersion { get; set; } + public string? BetaVersion { get; } /// The mods on the wiki. - public WikiModEntry[] Mods { get; set; } + public WikiModEntry[] Mods { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The stable game version. + /// The beta game version (if any). + /// The mods on the wiki. + public WikiModList(string? stableVersion, string? betaVersion, WikiModEntry[] mods) + { + this.StableVersion = stableVersion; + this.BetaVersion = betaVersion; + this.Mods = mods; + } } } diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs index 2cb27e11..3713758f 100644 --- a/src/SMAPI.Toolkit/SemanticVersion.cs +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -119,6 +119,9 @@ namespace StardewModdingAPI.Toolkit } /// +#if NET5_0_OR_GREATER + [MemberNotNullWhen(true, nameof(SemanticVersion.PrereleaseTag))] +#endif public bool IsPrerelease() { return !string.IsNullOrWhiteSpace(this.PrereleaseTag); diff --git a/src/SMAPI.Toolkit/SemanticVersionComparer.cs b/src/SMAPI.Toolkit/SemanticVersionComparer.cs index 85c974bd..2eca30df 100644 --- a/src/SMAPI.Toolkit/SemanticVersionComparer.cs +++ b/src/SMAPI.Toolkit/SemanticVersionComparer.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; namespace StardewModdingAPI.Toolkit { /// A comparer for semantic versions based on the field. - public class SemanticVersionComparer : IComparer + public class SemanticVersionComparer : IComparer { /********* ** Accessors diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs index 7706b276..49356f76 100644 --- a/src/SMAPI.Web/BackgroundService.cs +++ b/src/SMAPI.Web/BackgroundService.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Diagnostics.CodeAnalysis; using System.Threading; @@ -21,13 +19,17 @@ namespace StardewModdingAPI.Web ** Fields *********/ /// The background task server. - private static BackgroundJobServer JobServer; + private static BackgroundJobServer? JobServer; /// The cache in which to store wiki metadata. - private static IWikiCacheRepository WikiCache; + private static IWikiCacheRepository? WikiCache; /// The cache in which to store mod data. - private static IModCacheRepository ModCache; + private static IModCacheRepository? ModCache; + + /// Whether the service has been started. + [MemberNotNullWhen(true, nameof(BackgroundService.JobServer), nameof(BackgroundService.WikiCache), nameof(BackgroundService.ModCache))] + private static bool IsStarted { get; set; } /********* @@ -61,6 +63,8 @@ namespace StardewModdingAPI.Web RecurringJob.AddOrUpdate(() => BackgroundService.UpdateWikiAsync(), "*/10 * * * *"); // every 10 minutes RecurringJob.AddOrUpdate(() => BackgroundService.RemoveStaleModsAsync(), "0 * * * *"); // hourly + BackgroundService.IsStarted = true; + return Task.CompletedTask; } @@ -68,6 +72,8 @@ namespace StardewModdingAPI.Web /// Tracks whether the shutdown process should no longer be graceful. public async Task StopAsync(CancellationToken cancellationToken) { + BackgroundService.IsStarted = false; + if (BackgroundService.JobServer != null) await BackgroundService.JobServer.WaitForShutdownAsync(cancellationToken); } @@ -75,6 +81,8 @@ namespace StardewModdingAPI.Web /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. public void Dispose() { + BackgroundService.IsStarted = false; + BackgroundService.JobServer?.Dispose(); } @@ -85,6 +93,9 @@ namespace StardewModdingAPI.Web [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })] public static async Task UpdateWikiAsync() { + if (!BackgroundService.IsStarted) + throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync(); BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods); } @@ -92,6 +103,9 @@ namespace StardewModdingAPI.Web /// Remove mods which haven't been requested in over 48 hours. public static Task RemoveStaleModsAsync() { + if (!BackgroundService.IsStarted) + throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + BackgroundService.ModCache.RemoveStaleMods(TimeSpan.FromHours(48)); return Task.CompletedTask; } diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs index f7834b9c..522d77cd 100644 --- a/src/SMAPI.Web/Controllers/IndexController.cs +++ b/src/SMAPI.Web/Controllers/IndexController.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -59,8 +57,8 @@ namespace StardewModdingAPI.Web.Controllers { // choose versions ReleaseVersion[] versions = await this.GetReleaseVersionsAsync(); - ReleaseVersion stableVersion = versions.LastOrDefault(version => !version.IsForDevs); - ReleaseVersion stableVersionForDevs = versions.LastOrDefault(version => version.IsForDevs); + ReleaseVersion? stableVersion = versions.LastOrDefault(version => !version.IsForDevs); + ReleaseVersion? stableVersionForDevs = versions.LastOrDefault(version => version.IsForDevs); // render view IndexVersionModel stableVersionModel = stableVersion != null @@ -91,7 +89,7 @@ namespace StardewModdingAPI.Web.Controllers entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); // get latest stable release - GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false); + GitRelease? release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false); // strip 'noinclude' blocks from release description if (release != null) @@ -113,7 +111,7 @@ namespace StardewModdingAPI.Web.Controllers /// Get a parsed list of SMAPI downloads for a release. /// The GitHub release. - private IEnumerable ParseReleaseVersions(GitRelease release) + private IEnumerable ParseReleaseVersions(GitRelease? release) { if (release?.Assets == null) yield break; @@ -124,7 +122,7 @@ namespace StardewModdingAPI.Web.Controllers continue; Match match = Regex.Match(asset.FileName, @"SMAPI-(?[\d\.]+(?:-.+)?)-installer(?-for-developers)?.zip"); - if (!match.Success || !SemanticVersion.TryParse(match.Groups["version"].Value, out ISemanticVersion version)) + if (!match.Success || !SemanticVersion.TryParse(match.Groups["version"].Value, out ISemanticVersion? version)) continue; bool isForDevs = match.Groups["forDevs"].Success; diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs index 5791d834..e78aeeb6 100644 --- a/src/SMAPI.Web/Controllers/JsonValidatorController.cs +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.IO; @@ -66,7 +64,7 @@ namespace StardewModdingAPI.Web.Controllers [Route("json/{schemaName}")] [Route("json/{schemaName}/{id}")] [Route("json/{schemaName}/{id}/{operation}")] - public async Task Index(string schemaName = null, string id = null, string operation = null) + public async Task Index(string? schemaName = null, string? id = null, string? operation = null) { // parse arguments schemaName = this.NormalizeSchemaName(schemaName); @@ -81,7 +79,7 @@ namespace StardewModdingAPI.Web.Controllers return this.View("Index", result); // fetch raw JSON - StoredFileInfo file = await this.Storage.GetAsync(id, renew); + StoredFileInfo file = await this.Storage.GetAsync(id!, renew); if (string.IsNullOrWhiteSpace(file.Content)) return this.View("Index", result.SetUploadError("The JSON file seems to be empty.")); result.SetContent(file.Content, expiry: file.Expiry, uploadWarning: file.Warning); @@ -105,7 +103,7 @@ namespace StardewModdingAPI.Web.Controllers } catch (JsonReaderException ex) { - return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path, ex.Message, ErrorType.None))); + return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path!, ex.Message, ErrorType.None))); } // format JSON @@ -121,7 +119,7 @@ namespace StardewModdingAPI.Web.Controllers // load schema JSchema schema; { - FileInfo schemaFile = this.FindSchemaFile(schemaName); + FileInfo? schemaFile = this.FindSchemaFile(schemaName); if (schemaFile == null) return this.View("Index", result.SetParseError($"Invalid schema '{schemaName}'.")); schema = JSchema.Parse(System.IO.File.ReadAllText(schemaFile.FullName)); @@ -144,7 +142,7 @@ namespace StardewModdingAPI.Web.Controllers /// Save raw JSON data. [HttpPost, AllowLargePosts] [Route("json")] - public async Task PostAsync(JsonValidatorRequestModel request) + public async Task PostAsync(JsonValidatorRequestModel? request) { if (request == null) return this.View("Index", this.GetModel(null, null, isEditView: true).SetUploadError("The request seems to be invalid.")); @@ -163,7 +161,7 @@ namespace StardewModdingAPI.Web.Controllers return this.View("Index", this.GetModel(result.ID, schemaName, isEditView: true).SetContent(input, null).SetUploadError(result.UploadError)); // redirect to view - return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName, id = result.ID })); + return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName, id = result.ID })!); } @@ -174,14 +172,14 @@ namespace StardewModdingAPI.Web.Controllers /// The stored file ID. /// The schema name with which the JSON was validated. /// Whether to show the edit view. - private JsonValidatorModel GetModel(string pasteID, string schemaName, bool isEditView) + private JsonValidatorModel GetModel(string? pasteID, string? schemaName, bool isEditView) { return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats, isEditView); } /// Get a normalized schema name, or the if blank. /// The raw schema name to normalize. - private string NormalizeSchemaName(string schemaName) + private string NormalizeSchemaName(string? schemaName) { schemaName = schemaName?.Trim().ToLower(); return !string.IsNullOrWhiteSpace(schemaName) @@ -191,7 +189,7 @@ namespace StardewModdingAPI.Web.Controllers /// Get the schema file given its unique ID. /// The schema ID. - private FileInfo FindSchemaFile(string id) + private FileInfo? FindSchemaFile(string? id) { // normalize ID id = id?.Trim().ToLower(); @@ -216,13 +214,13 @@ namespace StardewModdingAPI.Web.Controllers // skip through transparent errors if (this.IsTransparentError(error)) { - foreach (var model in error.ChildErrors.SelectMany(this.GetErrorModels)) + foreach (JsonValidatorErrorModel model in error.ChildErrors.SelectMany(this.GetErrorModels)) yield return model; yield break; } // get message - string message = this.GetOverrideError(error); + string? message = this.GetOverrideError(error); if (message == null || message == this.TransparentToken) message = this.FlattenErrorMessage(error); @@ -236,7 +234,7 @@ namespace StardewModdingAPI.Web.Controllers private string FlattenErrorMessage(ValidationError error, int indent = 0) { // get override - string message = this.GetOverrideError(error); + string? message = this.GetOverrideError(error); if (message != null && message != this.TransparentToken) return message; @@ -257,7 +255,7 @@ namespace StardewModdingAPI.Web.Controllers break; case ErrorType.Required: - message = $"Missing required fields: {string.Join(", ", (List)error.Value)}."; + message = $"Missing required fields: {string.Join(", ", (List)error.Value!)}."; break; } @@ -274,7 +272,7 @@ namespace StardewModdingAPI.Web.Controllers if (!error.ChildErrors.Any()) return false; - string @override = this.GetOverrideError(error); + string? @override = this.GetOverrideError(error); return @override == this.TransparentToken || (error.ErrorType == ErrorType.Then && @override == null); @@ -282,18 +280,18 @@ namespace StardewModdingAPI.Web.Controllers /// Get an override error from the JSON schema, if any. /// The schema validation error. - private string GetOverrideError(ValidationError error) + private string? GetOverrideError(ValidationError error) { - string GetRawOverrideError() + string? GetRawOverrideError() { // get override errors - IDictionary errors = this.GetExtensionField>(error.Schema, "@errorMessages"); + IDictionary? errors = this.GetExtensionField>(error.Schema, "@errorMessages"); if (errors == null) return null; - errors = new Dictionary(errors, StringComparer.OrdinalIgnoreCase); + errors = new Dictionary(errors, StringComparer.OrdinalIgnoreCase); // match error by type and message - foreach ((string target, string errorMessage) in errors) + foreach ((string target, string? errorMessage) in errors) { if (!target.Contains(":")) continue; @@ -304,7 +302,7 @@ namespace StardewModdingAPI.Web.Controllers } // match by type - return errors.TryGetValue(error.ErrorType.ToString(), out string message) + return errors.TryGetValue(error.ErrorType.ToString(), out string? message) ? message?.Trim() : null; } @@ -317,7 +315,7 @@ namespace StardewModdingAPI.Web.Controllers /// The field type. /// The schema whose extension fields to search. /// The case-insensitive field key. - private T GetExtensionField(JSchema schema, string key) + private T? GetExtensionField(JSchema schema, string key) { foreach ((string curKey, JToken value) in schema.ExtensionData) { @@ -330,7 +328,7 @@ namespace StardewModdingAPI.Web.Controllers /// Format a schema value for display. /// The value to format. - private string FormatValue(object value) + private string FormatValue(object? value) { return value switch { diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index 93f2613e..33af5a81 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -69,7 +69,7 @@ namespace StardewModdingAPI.Web.Controllers case LogViewFormat.RawDownload: { - string content = file.Error ?? file.Content; + string content = file.Error ?? file.Content ?? string.Empty; return this.File(Encoding.UTF8.GetBytes(content), "plain/text", $"SMAPI log ({id}).txt"); } @@ -97,7 +97,7 @@ namespace StardewModdingAPI.Web.Controllers return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError)); // redirect to view - return this.Redirect(this.Url.PlainAction("Index", "LogParser", new { id = uploadResult.ID })); + return this.Redirect(this.Url.PlainAction("Index", "LogParser", new { id = uploadResult.ID })!); } @@ -109,7 +109,7 @@ namespace StardewModdingAPI.Web.Controllers /// When the uploaded file will no longer be available. /// A non-blocking warning while uploading the log. /// An error which occurred while uploading the log. - private LogParserModel GetModel(string? pasteID, DateTime? expiry = null, string? uploadWarning = null, string? uploadError = null) + private LogParserModel GetModel(string? pasteID, DateTimeOffset? expiry = null, string? uploadWarning = null, string? uploadError = null) { Platform? platform = this.DetectClientPlatform(); diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 3dc1e366..401bba4f 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -1,7 +1,6 @@ -#nullable disable - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -78,7 +77,7 @@ namespace StardewModdingAPI.Web.Controllers /// The mod search criteria. /// The requested API version. [HttpPost] - public async Task> PostAsync([FromBody] ModSearchModel model, [FromRoute] string version) + public async Task> PostAsync([FromBody] ModSearchModel? model, [FromRoute] string version) { if (model?.Mods == null) return Array.Empty(); @@ -94,16 +93,16 @@ namespace StardewModdingAPI.Web.Controllers continue; // special case: if this is an update check for the official SMAPI repo, check the Nexus mod page for beta versions - if (mod.ID == config.SmapiInfo.ID && mod.UpdateKeys?.Any(key => key == config.SmapiInfo.DefaultUpdateKey) == true && mod.InstalledVersion?.IsPrerelease() == true) - mod.UpdateKeys = mod.UpdateKeys.Concat(config.SmapiInfo.AddBetaUpdateKeys).ToArray(); + if (mod.ID == config.SmapiInfo.ID && mod.UpdateKeys.Any(key => key == config.SmapiInfo.DefaultUpdateKey) && mod.InstalledVersion?.IsPrerelease() == true) + mod.AddUpdateKeys(config.SmapiInfo.AddBetaUpdateKeys); // fetch result ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata, model.ApiVersion); if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null)) { - var errors = new List(result.Errors); - errors.Add($"This API can't suggest an update because {nameof(model.ApiVersion)} or {nameof(mod.InstalledVersion)} are null, and you didn't specify {nameof(model.IncludeExtendedMetadata)} to get other info. See the SMAPI technical docs for usage."); - result.Errors = errors.ToArray(); + result.Errors = result.Errors + .Concat(new[] { $"This API can't suggest an update because {nameof(model.ApiVersion)} or {nameof(mod.InstalledVersion)} are null, and you didn't specify {nameof(model.IncludeExtendedMetadata)} to get other info. See the SMAPI technical docs for usage." }) + .ToArray(); } mods[mod.ID] = result; @@ -123,26 +122,26 @@ namespace StardewModdingAPI.Web.Controllers /// Whether to include extended metadata for each mod. /// The SMAPI version installed by the player. /// Returns the mod data if found, else null. - private async Task GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion apiVersion) + private async Task GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion? apiVersion) { // cross-reference data - ModDataRecord record = this.ModDatabase.Get(search.ID); - WikiModEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.OrdinalIgnoreCase)); + ModDataRecord? record = this.ModDatabase.Get(search.ID); + WikiModEntry? wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.OrdinalIgnoreCase)); UpdateKey[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray(); - ModOverrideConfig overrides = this.Config.Value.ModOverrides.FirstOrDefault(p => p.ID.Equals(search.ID?.Trim(), StringComparison.OrdinalIgnoreCase)); + ModOverrideConfig? overrides = this.Config.Value.ModOverrides.FirstOrDefault(p => p.ID.Equals(search.ID.Trim(), StringComparison.OrdinalIgnoreCase)); bool allowNonStandardVersions = overrides?.AllowNonStandardVersions ?? false; // SMAPI versions with a '-beta' tag indicate major changes that may need beta mod versions. // This doesn't apply to normal prerelease versions which have an '-alpha' tag. - bool isSmapiBeta = apiVersion.IsPrerelease() && apiVersion.PrereleaseTag.StartsWith("beta"); + bool isSmapiBeta = apiVersion != null && apiVersion.IsPrerelease() && apiVersion.PrereleaseTag.StartsWith("beta"); // get latest versions - ModEntryModel result = new() { ID = search.ID }; + ModEntryModel result = new(search.ID); IList errors = new List(); - ModEntryVersionModel main = null; - ModEntryVersionModel optional = null; - ModEntryVersionModel unofficial = null; - ModEntryVersionModel unofficialForBeta = null; + ModEntryVersionModel? main = null; + ModEntryVersionModel? optional = null; + ModEntryVersionModel? unofficial = null; + ModEntryVersionModel? unofficialForBeta = null; foreach (UpdateKey updateKey in updateKeys) { // validate update key @@ -162,9 +161,9 @@ namespace StardewModdingAPI.Web.Controllers // handle versions if (this.IsNewer(data.Version, main?.Version)) - main = new ModEntryVersionModel(data.Version, data.Url); + main = new ModEntryVersionModel(data.Version, data.Url!); if (this.IsNewer(data.PreviewVersion, optional?.Version)) - optional = new ModEntryVersionModel(data.PreviewVersion, data.Url); + optional = new ModEntryVersionModel(data.PreviewVersion, data.Url!); } // get unofficial version @@ -172,7 +171,7 @@ namespace StardewModdingAPI.Web.Controllers unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}"); // get unofficial version for beta - if (wikiEntry?.HasBetaInfo == true) + if (wikiEntry is { HasBetaInfo: true }) { if (wikiEntry.BetaCompatibility.Status == WikiCompatibilityStatus.Unofficial) { @@ -198,13 +197,13 @@ namespace StardewModdingAPI.Web.Controllers if (overrides?.SetUrl != null) { if (main != null) - main.Url = overrides.SetUrl; + main = new(main.Version, overrides.SetUrl); if (optional != null) - optional.Url = overrides.SetUrl; + optional = new(optional.Version, overrides.SetUrl); } // get recommended update (if any) - ISemanticVersion installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.Overrides?.ChangeLocalVersions, allowNonStandard: allowNonStandardVersions); + ISemanticVersion? installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.Overrides?.ChangeLocalVersions, allowNonStandard: allowNonStandardVersions); if (apiVersion != null && installedVersion != null) { // get newer versions @@ -219,7 +218,7 @@ namespace StardewModdingAPI.Web.Controllers updates.Add(unofficialForBeta); // get newest version - ModEntryVersionModel newest = null; + ModEntryVersionModel? newest = null; foreach (ModEntryVersionModel update in updates) { if (newest == null || update.Version.IsNewerThan(newest.Version)) @@ -245,7 +244,7 @@ namespace StardewModdingAPI.Web.Controllers /// The current semantic version. /// The target semantic version. /// Whether the user enabled the beta channel and should be offered prerelease updates. - private bool IsRecommendedUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) + private bool IsRecommendedUpdate(ISemanticVersion currentVersion, [NotNullWhen(true)] ISemanticVersion? newVersion, bool useBetaChannel) { return newVersion != null @@ -256,7 +255,7 @@ namespace StardewModdingAPI.Web.Controllers /// Get whether a version is newer than an version. /// The current version. /// The other version. - private bool IsNewer(ISemanticVersion current, ISemanticVersion other) + private bool IsNewer([NotNullWhen(true)] ISemanticVersion? current, ISemanticVersion? other) { return current != null && (other == null || other.IsOlderThan(current)); } @@ -265,17 +264,20 @@ namespace StardewModdingAPI.Web.Controllers /// The namespaced update key. /// Whether to allow non-standard versions. /// The changes to apply to remote versions for update checks. - private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions) + private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions) { + if (!updateKey.LooksValid) + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"Invalid update key '{updateKey}'."); + // get mod page IModPage page; { bool isCached = - this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached cachedMod) + this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached? cachedMod) && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes); if (isCached) - page = cachedMod.Data; + page = cachedMod!.Data; else { page = await this.ModSites.GetModPageAsync(updateKey); @@ -291,7 +293,7 @@ namespace StardewModdingAPI.Web.Controllers /// The specified update keys. /// The mod's entry in SMAPI's internal database. /// The mod's entry in the wiki list. - private IEnumerable GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) + private IEnumerable GetUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry) { // get unique update keys List updateKeys = this.GetUnfilteredUpdateKeys(specifiedKeys, record, entry) @@ -310,7 +312,7 @@ namespace StardewModdingAPI.Web.Controllers // if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority { var removeKeys = new HashSet(); - foreach (var key in updateKeys) + foreach (UpdateKey key in updateKeys) { if (key.Subkey != null) removeKeys.Add(new UpdateKey(key.Site, key.ID, null)); @@ -326,7 +328,7 @@ namespace StardewModdingAPI.Web.Controllers /// The specified update keys. /// The mod's entry in SMAPI's internal database. /// The mod's entry in the wiki list. - private IEnumerable GetUnfilteredUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) + private IEnumerable GetUnfilteredUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry) { // specified update keys foreach (string key in specifiedKeys ?? Array.Empty()) @@ -337,7 +339,7 @@ namespace StardewModdingAPI.Web.Controllers // default update key { - string defaultKey = record?.GetDefaultUpdateKey(); + string? defaultKey = record?.GetDefaultUpdateKey(); if (!string.IsNullOrWhiteSpace(defaultKey)) yield return defaultKey; } diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsController.cs index 5292e1ce..919afa5b 100644 --- a/src/SMAPI.Web/Controllers/ModsController.cs +++ b/src/SMAPI.Web/Controllers/ModsController.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using System.Linq; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc; @@ -54,8 +53,8 @@ namespace StardewModdingAPI.Web.Controllers public ModListModel FetchData() { // fetch cached data - if (!this.Cache.TryGetWikiMetadata(out Cached metadata)) - return new ModListModel(); + if (!this.Cache.TryGetWikiMetadata(out Cached? metadata)) + return new ModListModel(null, null, Array.Empty(), lastUpdated: DateTimeOffset.UtcNow, isStale: true); // build model return new ModListModel( diff --git a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs index 108ceff7..bd414ea2 100644 --- a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs +++ b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Mvc.Filters; @@ -42,7 +40,7 @@ namespace StardewModdingAPI.Web.Framework public void OnAuthorization(AuthorizationFilterContext context) { IFeatureCollection features = context.HttpContext.Features; - IFormFeature formFeature = features.Get(); + IFormFeature? formFeature = features.Get(); if (formFeature?.Form == null) { diff --git a/src/SMAPI.Web/Framework/Caching/Cached.cs b/src/SMAPI.Web/Framework/Caching/Cached.cs index aabbf146..b393e1e1 100644 --- a/src/SMAPI.Web/Framework/Caching/Cached.cs +++ b/src/SMAPI.Web/Framework/Caching/Cached.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; namespace StardewModdingAPI.Web.Framework.Caching @@ -12,21 +10,18 @@ namespace StardewModdingAPI.Web.Framework.Caching ** Accessors *********/ /// The cached data. - public T Data { get; set; } + public T Data { get; } /// When the data was last updated. - public DateTimeOffset LastUpdated { get; set; } + public DateTimeOffset LastUpdated { get; } /// When the data was last requested through the mod API. - public DateTimeOffset LastRequested { get; set; } + public DateTimeOffset LastRequested { get; internal set; } /********* ** Public methods *********/ - /// Construct an empty instance. - public Cached() { } - /// Construct an instance. /// The cached data. public Cached(T data) diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs index 2020d747..fb74e9da 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -1,6 +1,5 @@ -#nullable disable - using System; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework.Caching.Mods @@ -16,7 +15,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// The mod's unique ID within the . /// The fetched mod. /// Whether to update the mod's 'last requested' date. - bool TryGetMod(ModSiteKey site, string id, out Cached mod, bool markRequested = true); + bool TryGetMod(ModSiteKey site, string id, [NotNullWhen(true)] out Cached? mod, bool markRequested = true); /// Save data fetched for a mod. /// The mod site on which the mod is found. diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs index 338562d8..4ba0bd20 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs @@ -1,7 +1,6 @@ -#nullable disable - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; @@ -25,7 +24,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// The mod's unique ID within the . /// The fetched mod. /// Whether to update the mod's 'last requested' date. - public bool TryGetMod(ModSiteKey site, string id, out Cached mod, bool markRequested = true) + public bool TryGetMod(ModSiteKey site, string id, [NotNullWhen(true)] out Cached? mod, bool markRequested = true) { // get mod if (!this.Mods.TryGetValue(this.GetKey(site, id), out var cachedMod)) diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs index 6edafddc..b8a0df34 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs @@ -1,7 +1,6 @@ -#nullable disable - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.Framework.Caching.Wiki @@ -14,16 +13,16 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki *********/ /// Get the cached wiki metadata. /// The fetched metadata. - bool TryGetWikiMetadata(out Cached metadata); + bool TryGetWikiMetadata([NotNullWhen(true)] out Cached? metadata); /// Get the cached wiki mods. /// A filter to apply, if any. - IEnumerable> GetWikiMods(Func filter = null); + IEnumerable> GetWikiMods(Func? filter = null); /// Save data fetched from the wiki compatibility list. /// The current stable Stardew Valley version. /// The current beta Stardew Valley version. /// The mod data. - void SaveWikiData(string stableVersion, string betaVersion, IEnumerable mods); + void SaveWikiData(string? stableVersion, string? betaVersion, IEnumerable mods); } } diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs index d1ccb9c7..8b4338e2 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs @@ -1,7 +1,6 @@ -#nullable disable - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; @@ -14,7 +13,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki ** Fields *********/ /// The saved wiki metadata. - private Cached Metadata; + private Cached? Metadata; /// The cached wiki data. private Cached[] Mods = Array.Empty>(); @@ -25,7 +24,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki *********/ /// Get the cached wiki metadata. /// The fetched metadata. - public bool TryGetWikiMetadata(out Cached metadata) + public bool TryGetWikiMetadata([NotNullWhen(true)] out Cached? metadata) { metadata = this.Metadata; return metadata != null; @@ -33,7 +32,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki /// Get the cached wiki mods. /// A filter to apply, if any. - public IEnumerable> GetWikiMods(Func filter = null) + public IEnumerable> GetWikiMods(Func? filter = null) { foreach (var mod in this.Mods) { @@ -46,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki /// The current stable Stardew Valley version. /// The current beta Stardew Valley version. /// The mod data. - public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable mods) + public void SaveWikiData(string? stableVersion, string? betaVersion, IEnumerable mods) { this.Metadata = new Cached(new WikiMetadata(stableVersion, betaVersion)); this.Mods = mods.Select(mod => new Cached(mod)).ToArray(); diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs index 6ae42488..f53ea201 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace StardewModdingAPI.Web.Framework.Caching.Wiki { /// The model for cached wiki metadata. @@ -9,22 +7,19 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki ** Accessors *********/ /// The current stable Stardew Valley version. - public string StableVersion { get; set; } + public string? StableVersion { get; } /// The current beta Stardew Valley version. - public string BetaVersion { get; set; } + public string? BetaVersion { get; } /********* ** Public methods *********/ - /// Construct an instance. - public WikiMetadata() { } - /// Construct an instance. /// The current stable Stardew Valley version. /// The current beta Stardew Valley version. - public WikiMetadata(string stableVersion, string betaVersion) + public WikiMetadata(string? stableVersion, string? betaVersion) { this.StableVersion = stableVersion; this.BetaVersion = betaVersion; diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs index 4d041c1b..ce0f1122 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Net; using System.Threading.Tasks; @@ -44,7 +42,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish /// Get update check info about a mod. /// The mod ID. - public async Task GetModData(string id) + public async Task GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -53,7 +51,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); // fetch HTML - string html; + string? html; try { html = await this.Client @@ -69,7 +67,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish // extract mod info string url = this.GetModUrl(parsedId); - string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; + string? version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; string name = doc.DocumentNode.SelectSingleNode("//h1").ChildNodes[0].InnerText.Trim(); if (name.StartsWith("[SMAPI]")) name = name.Substring("[SMAPI]".Length).TrimStart(); @@ -81,7 +79,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } @@ -92,7 +90,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish /// The mod ID. private string GetModUrl(uint id) { - UriBuilder builder = new(this.Client.BaseClient.BaseAddress); + UriBuilder builder = new(this.Client.BaseClient.BaseAddress!); builder.Path += string.Format(this.ModPageUrlFormat, id); return builder.Uri.ToString(); } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs index 5ef369d5..d351b42d 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Collections.Generic; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -42,7 +40,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge /// Get update check info about a mod. /// The mod ID. - public async Task GetModData(string id) + public async Task GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -51,9 +49,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); // get raw data - ModModel mod = await this.Client + ModModel? mod = await this.Client .GetAsync($"addon/{parsedId}") - .As(); + .As(); if (mod == null) return page.SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); @@ -73,7 +71,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } @@ -82,9 +80,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge *********/ /// Get a raw version string for a mod file, if available. /// The file whose version to get. - private string GetRawVersion(ModFileModel file) + private string? GetRawVersion(ModFileModel file) { - Match match = this.VersionInNamePattern.Match(file.DisplayName); + Match match = this.VersionInNamePattern.Match(file.DisplayName ?? ""); if (!match.Success) match = this.VersionInNamePattern.Match(file.FileName); diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs index eabef9f0..e9adcf20 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs @@ -1,14 +1,28 @@ -#nullable disable - namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels { /// Metadata from the CurseForge API about a mod file. public class ModFileModel { + /********* + ** Accessors + *********/ /// The file name as downloaded. - public string FileName { get; set; } + public string FileName { get; } /// The file display name. - public string DisplayName { get; set; } + public string? DisplayName { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The file name as downloaded. + /// The file display name. + public ModFileModel(string fileName, string? displayName) + { + this.FileName = fileName; + this.DisplayName = displayName; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs index a95df7f1..fd7796f2 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs @@ -1,20 +1,38 @@ -#nullable disable - namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels { /// An mod from the CurseForge API. public class ModModel { + /********* + ** Accessors + *********/ /// The mod's unique ID on CurseForge. - public int ID { get; set; } + public int ID { get; } /// The mod name. - public string Name { get; set; } + public string Name { get; } /// The web URL for the mod page. - public string WebsiteUrl { get; set; } + public string WebsiteUrl { get; } /// The available file downloads. - public ModFileModel[] LatestFiles { get; set; } + public ModFileModel[] LatestFiles { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's unique ID on CurseForge. + /// The mod name. + /// The web URL for the mod page. + /// The available file downloads. + public ModModel(int id, string name, string websiteUrl, ModFileModel[] latestFiles) + { + this.ID = id; + this.Name = name; + this.WebsiteUrl = websiteUrl; + this.LatestFiles = latestFiles; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs index 919072b0..548f17c3 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace StardewModdingAPI.Web.Framework.Clients { /// Generic metadata about a file download on a mod page. @@ -9,26 +7,23 @@ namespace StardewModdingAPI.Web.Framework.Clients ** Accessors *********/ /// The download's display name. - public string Name { get; set; } + public string Name { get; } /// The download's description. - public string Description { get; set; } + public string? Description { get; } /// The download's file version. - public string Version { get; set; } + public string? Version { get; } /********* ** Public methods *********/ - /// Construct an empty instance. - public GenericModDownload() { } - /// Construct an instance. /// The download's display name. /// The download's description. /// The download's file version. - public GenericModDownload(string name, string description, string version) + public GenericModDownload(string name, string? description, string? version) { this.Name = name; this.Description = description; diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs index 4788aa2a..5353c7e1 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -1,7 +1,6 @@ -#nullable disable - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; @@ -20,30 +19,31 @@ namespace StardewModdingAPI.Web.Framework.Clients public string Id { get; set; } /// The mod name. - public string Name { get; set; } + public string? Name { get; set; } /// The mod's semantic version number. - public string Version { get; set; } + public string? Version { get; set; } /// The mod's web URL. - public string Url { get; set; } + public string? Url { get; set; } /// The mod downloads. public IModDownload[] Downloads { get; set; } = Array.Empty(); /// The mod availability status on the remote site. - public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + public RemoteModStatus Status { get; set; } = RemoteModStatus.InvalidData; /// A user-friendly error which indicates why fetching the mod info failed (if applicable). - public string Error { get; set; } + public string? Error { get; set; } + + /// Whether the mod data is valid. + [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] + public bool IsValid => this.Status == RemoteModStatus.Ok; /********* ** Public methods *********/ - /// Construct an empty instance. - public GenericModPage() { } - /// Construct an instance. /// The mod site containing the mod. /// The mod's unique ID within the site. @@ -58,12 +58,13 @@ namespace StardewModdingAPI.Web.Framework.Clients /// The mod's semantic version number. /// The mod's web URL. /// The mod downloads. - public IModPage SetInfo(string name, string version, string url, IEnumerable downloads) + public IModPage SetInfo(string name, string? version, string url, IEnumerable downloads) { this.Name = name; this.Version = version; this.Url = url; this.Downloads = downloads.ToArray(); + this.Status = RemoteModStatus.Ok; return this; } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs index 39ebf94e..dbce9368 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs @@ -1,5 +1,3 @@ -#nullable disable - using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework.Clients.GitHub @@ -7,16 +5,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// A GitHub download attached to a release. internal class GitAsset { + /********* + ** Accessors + *********/ /// The file name. [JsonProperty("name")] - public string FileName { get; set; } + public string FileName { get; } /// The file content type. [JsonProperty("content_type")] - public string ContentType { get; set; } + public string ContentType { get; } /// The download URL. [JsonProperty("browser_download_url")] - public string DownloadUrl { get; set; } + public string DownloadUrl { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The file name. + /// The file content type. + /// The download URL. + public GitAsset(string fileName, string contentType, string downloadUrl) + { + this.FileName = fileName; + this.ContentType = contentType; + this.DownloadUrl = downloadUrl; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 0e68e2c2..785979a5 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Linq; using System.Net; @@ -35,26 +33,26 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// The Accept header value expected by the GitHub API. /// The username with which to authenticate to the GitHub API. /// The password with which to authenticate to the GitHub API. - public GitHubClient(string baseUrl, string userAgent, string acceptHeader, string username, string password) + public GitHubClient(string baseUrl, string userAgent, string acceptHeader, string? username, string? password) { this.Client = new FluentClient(baseUrl) .SetUserAgent(userAgent) .AddDefault(req => req.WithHeader("Accept", acceptHeader)); if (!string.IsNullOrWhiteSpace(username)) - this.Client = this.Client.SetBasicAuthentication(username, password); + this.Client = this.Client.SetBasicAuthentication(username, password!); } /// Get basic metadata for a GitHub repository, if available. /// The repository key (like Pathoschild/SMAPI). /// Returns the repository info if it exists, else null. - public async Task GetRepositoryAsync(string repo) + public async Task GetRepositoryAsync(string repo) { this.AssertKeyFormat(repo); try { return await this.Client .GetAsync($"repos/{repo}") - .As(); + .As(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { @@ -66,7 +64,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// The repository key (like Pathoschild/SMAPI). /// Whether to return a prerelease version if it's latest. /// Returns the release if found, else null. - public async Task GetLatestReleaseAsync(string repo, bool includePrerelease = false) + public async Task GetLatestReleaseAsync(string repo, bool includePrerelease = false) { this.AssertKeyFormat(repo); try @@ -81,7 +79,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub return await this.Client .GetAsync($"repos/{repo}/releases/latest") - .As(); + .As(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { @@ -91,7 +89,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// Get update check info about a mod. /// The mod ID. - public async Task GetModData(string id) + public async Task GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -99,15 +97,15 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/SMAPI'."); // fetch repo info - GitRepo repository = await this.GetRepositoryAsync(id); + GitRepo? repository = await this.GetRepositoryAsync(id); if (repository == null) return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); string name = repository.FullName; string url = $"{repository.WebUrl}/releases"; // get releases - GitRelease latest; - GitRelease preview; + GitRelease? latest; + GitRelease? preview; { // get latest release (whether preview or stable) latest = await this.GetLatestReleaseAsync(id, includePrerelease: true); @@ -118,7 +116,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub preview = null; if (latest.IsPrerelease) { - GitRelease release = await this.GetLatestReleaseAsync(id, includePrerelease: false); + GitRelease? release = await this.GetLatestReleaseAsync(id, includePrerelease: false); if (release != null) { preview = latest; @@ -129,8 +127,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub // get downloads IModDownload[] downloads = new[] { latest, preview } - .Where(release => release != null) - .Select(release => (IModDownload)new GenericModDownload(release.Name, release.Body, release.Tag)) + .Where(release => release is not null) + .Select(release => (IModDownload)new GenericModDownload(release!.Name, release.Body, release.Tag)) .ToArray(); // return info @@ -140,7 +138,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs index 275c775a..24d6c3c5 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs @@ -1,5 +1,3 @@ -#nullable disable - using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework.Clients.GitHub @@ -7,16 +5,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// The license info for a GitHub project. internal class GitLicense { + /********* + ** Accessors + *********/ /// The license display name. [JsonProperty("name")] - public string Name { get; set; } + public string Name { get; } /// The SPDX ID for the license. [JsonProperty("spdx_id")] - public string SpdxId { get; set; } + public string SpdxId { get; } /// The URL for the license info. [JsonProperty("url")] - public string Url { get; set; } + public string Url { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The license display name. + /// The SPDX ID for the license. + /// The URL for the license info. + public GitLicense(string name, string spdxId, string url) + { + this.Name = name; + this.SpdxId = spdxId; + this.Url = url; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs index 383775d2..9de6f020 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework.Clients.GitHub @@ -12,24 +11,45 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub *********/ /// The display name. [JsonProperty("name")] - public string Name { get; set; } + public string Name { get; } /// The semantic version string. [JsonProperty("tag_name")] - public string Tag { get; set; } + public string Tag { get; } /// The Markdown description for the release. - public string Body { get; set; } + public string Body { get; internal set; } /// Whether this is a draft version. [JsonProperty("draft")] - public bool IsDraft { get; set; } + public bool IsDraft { get; } /// Whether this is a prerelease version. [JsonProperty("prerelease")] - public bool IsPrerelease { get; set; } + public bool IsPrerelease { get; } /// The attached files. - public GitAsset[] Assets { get; set; } + public GitAsset[] Assets { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The display name. + /// The semantic version string. + /// The Markdown description for the release. + /// Whether this is a draft version. + /// Whether this is a prerelease version. + /// The attached files. + public GitRelease(string name, string tag, string? body, bool isDraft, bool isPrerelease, GitAsset[]? assets) + { + this.Name = name; + this.Tag = tag; + this.Body = body ?? string.Empty; + this.IsDraft = isDraft; + this.IsPrerelease = isPrerelease; + this.Assets = assets ?? Array.Empty(); + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs index 5b5ce6a6..879b5e49 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs @@ -1,5 +1,3 @@ -#nullable disable - using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework.Clients.GitHub @@ -7,16 +5,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// Basic metadata about a GitHub project. internal class GitRepo { + /********* + ** Accessors + *********/ /// The full repository name, including the owner. [JsonProperty("full_name")] - public string FullName { get; set; } + public string FullName { get; } /// The URL to the repository web page, if any. [JsonProperty("html_url")] - public string WebUrl { get; set; } + public string? WebUrl { get; } /// The code license, if any. [JsonProperty("license")] - public GitLicense License { get; set; } + public GitLicense? License { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The full repository name, including the owner. + /// The URL to the repository web page, if any. + /// The code license, if any. + public GitRepo(string fullName, string? webUrl, GitLicense? license) + { + this.FullName = fullName; + this.WebUrl = webUrl; + this.License = license; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index e1961416..886e32d3 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Threading.Tasks; @@ -14,12 +12,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// Get basic metadata for a GitHub repository, if available. /// The repository key (like Pathoschild/SMAPI). /// Returns the repository info if it exists, else null. - Task GetRepositoryAsync(string repo); + Task GetRepositoryAsync(string repo); /// Get the latest release for a GitHub repository. /// The repository key (like Pathoschild/SMAPI). /// Whether to return a prerelease version if it's latest. /// Returns the release if found, else null. - Task GetLatestReleaseAsync(string repo, bool includePrerelease = false); + Task GetLatestReleaseAsync(string repo, bool includePrerelease = false); } } diff --git a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs index 2cd1f635..3697ffae 100644 --- a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs +++ b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Threading.Tasks; using StardewModdingAPI.Toolkit.Framework.UpdateData; @@ -20,6 +18,6 @@ namespace StardewModdingAPI.Web.Framework.Clients *********/ /// Get update check info about a mod. /// The mod ID. - Task GetModData(string id); + Task GetModData(string id); } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs index 1a11a606..c60b2c90 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs @@ -1,6 +1,5 @@ -#nullable disable - using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit.Framework.UpdateData; @@ -43,9 +42,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /// Get update check info about a mod. /// The mod ID. - public async Task GetModData(string id) + [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The nullability is validated in this method.")] + public async Task GetModData(string id) { - var page = new GenericModPage(this.SiteKey, id); + IModPage page = new GenericModPage(this.SiteKey, id); if (!long.TryParse(id, out long parsedId)) return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); @@ -60,9 +60,11 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop Mods = true }) .As(); - ModModel mod = response.Mods[parsedId]; - if (mod.Mod?.Title == null || mod.Mod.ErrorCode.HasValue) - return null; + + if (!response.Mods.TryGetValue(parsedId, out ModModel? mod) || mod?.Mod is null) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop page with this ID."); + if (mod.Mod.ErrorCode is not null) + return page.SetError(RemoteModStatus.InvalidData, $"ModDrop returned error code {mod.Mod.ErrorCode} for mod ID '{id}'."); // get files var downloads = new List(); @@ -77,7 +79,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop } // return info - string name = mod.Mod?.Title; + string name = mod.Mod.Title; string url = string.Format(this.ModUrlFormat, id); return page.SetInfo(name: name, version: null, url: url, downloads: downloads); } @@ -85,7 +87,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs index dd6a95e0..31905338 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs @@ -1,5 +1,3 @@ -#nullable disable - using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels @@ -7,27 +5,53 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// Metadata from the ModDrop API about a mod file. public class FileDataModel { + /********* + ** Accessors + *********/ /// The file title. [JsonProperty("title")] - public string Name { get; set; } + public string Name { get; } /// The file description. [JsonProperty("desc")] - public string Description { get; set; } + public string Description { get; } /// The file version. - public string Version { get; set; } + public string Version { get; } /// Whether the file is deleted. - public bool IsDeleted { get; set; } + public bool IsDeleted { get; } /// Whether the file is hidden from users. - public bool IsHidden { get; set; } + public bool IsHidden { get; } /// Whether this is the default file for the mod. - public bool IsDefault { get; set; } + public bool IsDefault { get; } /// Whether this is an archived file. - public bool IsOld { get; set; } + public bool IsOld { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The file title. + /// The file description. + /// The file version. + /// Whether the file is deleted. + /// Whether the file is hidden from users. + /// Whether this is the default file for the mod. + /// Whether this is an archived file. + public FileDataModel(string name, string description, string version, bool isDeleted, bool isHidden, bool isDefault, bool isOld) + { + this.Name = name; + this.Description = description; + this.Version = version; + this.IsDeleted = isDeleted; + this.IsHidden = isHidden; + this.IsDefault = isDefault; + this.IsOld = isOld; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs index 6cae16d9..0654b576 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs @@ -1,17 +1,33 @@ -#nullable disable - namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels { /// Metadata about a mod from the ModDrop API. public class ModDataModel { + /********* + ** Accessors + *********/ /// The mod's unique ID on ModDrop. public int ID { get; set; } + /// The mod name. + public string Title { get; set; } + /// The error code, if any. public int? ErrorCode { get; set; } - /// The mod name. - public string Title { get; set; } + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's unique ID on ModDrop. + /// The mod name. + /// The error code, if any. + public ModDataModel(int id, string title, int? errorCode) + { + this.ID = id; + this.Title = title; + this.ErrorCode = errorCode; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs index 445e25cb..cb4be35c 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Collections.Generic; namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels @@ -7,7 +5,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// A list of mods from the ModDrop API. public class ModListModel { + /********* + ** Accessors + *********/ /// The mod data. - public IDictionary Mods { get; set; } + public IDictionary Mods { get; } = new Dictionary(); } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs index 8869193e..60b818d6 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs @@ -1,14 +1,28 @@ -#nullable disable - namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels { /// An entry in a mod list from the ModDrop API. public class ModModel { + /********* + ** Accessors + *********/ /// The available file downloads. - public FileDataModel[] Files { get; set; } + public FileDataModel[] Files { get; } /// The mod metadata. - public ModDataModel Mod { get; set; } + public ModDataModel Mod { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The available file downloads. + /// The mod metadata. + public ModModel(FileDataModel[] files, ModDataModel mod) + { + this.Files = files; + this.Mod = mod; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs index dd0bb94f..23b25f95 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -61,7 +59,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /// Get update check info about a mod. /// The mod ID. - public async Task GetModData(string id) + public async Task GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -72,7 +70,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus // adult content are hidden for anonymous users, so fall back to the API in that case. // Note that the API has very restrictive rate limits which means we can't just use it // for all cases. - NexusMod mod = await this.GetModFromWebsiteAsync(parsedId); + NexusMod? mod = await this.GetModFromWebsiteAsync(parsedId); if (mod?.Status == NexusModStatus.AdultContentForbidden) mod = await this.GetModFromApiAsync(parsedId); @@ -81,16 +79,16 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus return page.SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); // return info - page.SetInfo(name: mod.Name, url: mod.Url, version: mod.Version, downloads: mod.Downloads); + page.SetInfo(name: mod.Name ?? parsedId.ToString(), url: mod.Url ?? this.GetModUrl(parsedId), version: mod.Version, downloads: mod.Downloads); if (mod.Status != NexusModStatus.Ok) - page.SetError(RemoteModStatus.TemporaryError, mod.Error); + page.SetError(RemoteModStatus.TemporaryError, mod.Error!); return page; } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. public void Dispose() { - this.WebClient?.Dispose(); + this.WebClient.Dispose(); } @@ -100,7 +98,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /// Get metadata about a mod by scraping the Nexus website. /// The Nexus mod ID. /// Returns the mod info if found, else null. - private async Task GetModFromWebsiteAsync(uint id) + private async Task GetModFromWebsiteAsync(uint id) { // fetch HTML string html; @@ -116,35 +114,38 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus } // parse HTML - var doc = new HtmlDocument(); + HtmlDocument doc = new(); doc.LoadHtml(html); // handle Nexus error message - HtmlNode node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); + HtmlNode? node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); if (node != null) { string[] errorParts = node.InnerText.Trim().Split(new[] { '\n' }, 2, System.StringSplitOptions.RemoveEmptyEntries); string errorCode = errorParts[0]; - string errorText = errorParts.Length > 1 ? errorParts[1] : null; + string? errorText = errorParts.Length > 1 ? errorParts[1] : null; switch (errorCode.Trim().ToLower()) { case "not found": return null; default: - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = this.GetWebStatus(errorCode) }; + return new NexusMod( + status: this.GetWebStatus(errorCode), + error: $"Nexus error: {errorCode} ({errorText})." + ); } } // extract mod info string url = this.GetModUrl(id); - string name = doc.DocumentNode.SelectSingleNode("//div[@id='pagetitle']//h1")?.InnerText.Trim(); - string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); - SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion); + string? name = doc.DocumentNode.SelectSingleNode("//div[@id='pagetitle']//h1")?.InnerText.Trim(); + string? version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); + SemanticVersion.TryParse(version, out ISemanticVersion? parsedVersion); // extract files var downloads = new List(); - foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) + foreach (HtmlNode fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) { string sectionName = fileSection.Descendants("h2").First().InnerText; if (sectionName != "Main files" && sectionName != "Optional files") @@ -154,7 +155,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus { string fileName = container.GetDataAttribute("name").Value; string fileVersion = container.GetDataAttribute("version").Value; - string description = container.SelectSingleNode("following-sibling::*[1][self::dd]//div").InnerText?.Trim(); // get text of next
    tag; derived from https://stackoverflow.com/a/25535623/262123 + string? description = container.SelectSingleNode("following-sibling::*[1][self::dd]//div").InnerText?.Trim(); // get text of next
    tag; derived from https://stackoverflow.com/a/25535623/262123 downloads.Add( new GenericModDownload(fileName, description, fileVersion) @@ -163,13 +164,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus } // yield info - return new NexusMod - { - Name = name, - Version = parsedVersion?.ToString() ?? version, - Url = url, - Downloads = downloads.ToArray() - }; + return new NexusMod( + name: name ?? id.ToString(), + version: parsedVersion?.ToString() ?? version, + url: url, + downloads: downloads.ToArray() + ); } /// Get metadata about a mod from the Nexus API. @@ -182,22 +182,21 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); // yield info - return new NexusMod - { - Name = mod.Name, - Version = SemanticVersion.TryParse(mod.Version, out ISemanticVersion version) ? version?.ToString() : mod.Version, - Url = this.GetModUrl(id), - Downloads = files.Files + return new NexusMod( + name: mod.Name, + version: SemanticVersion.TryParse(mod.Version, out ISemanticVersion? version) ? version.ToString() : mod.Version, + url: this.GetModUrl(id), + downloads: files.Files .Select(file => (IModDownload)new GenericModDownload(file.Name, file.Description, file.FileVersion)) .ToArray() - }; + ); } /// Get the full mod page URL for a given ID. /// The mod ID. private string GetModUrl(uint id) { - UriBuilder builder = new(this.WebClient.BaseClient.BaseAddress); + UriBuilder builder = new(this.WebClient.BaseClient.BaseAddress!); builder.Path += string.Format(this.WebModUrlFormat, id); return builder.Uri.ToString(); } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs index 358c4633..3155cfda 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs @@ -1,5 +1,4 @@ -#nullable disable - +using System; using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels @@ -11,25 +10,53 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels ** Accessors *********/ /// The mod name. - public string Name { get; set; } + public string? Name { get; } /// The mod's semantic version number. - public string Version { get; set; } + public string? Version { get; } /// The mod's web URL. [JsonProperty("mod_page_uri")] - public string Url { get; set; } + public string? Url { get; } /// The mod's publication status. [JsonIgnore] - public NexusModStatus Status { get; set; } = NexusModStatus.Ok; + public NexusModStatus Status { get; } /// The files available to download. [JsonIgnore] - public IModDownload[] Downloads { get; set; } + public IModDownload[] Downloads { get; } /// A custom user-friendly error which indicates why fetching the mod info failed (if applicable). [JsonIgnore] - public string Error { get; set; } + public string? Error { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod name + /// The mod's semantic version number. + /// The mod's web URL. + /// The files available to download. + public NexusMod(string name, string? version, string url, IModDownload[] downloads) + { + this.Name = name; + this.Version = version; + this.Url = url; + this.Status = NexusModStatus.Ok; + this.Downloads = downloads; + } + + /// Construct an instance. + /// The mod's publication status. + /// A custom user-friendly error which indicates why fetching the mod info failed (if applicable). + public NexusMod(NexusModStatus status, string error) + { + this.Status = status; + this.Error = error; + this.Downloads = Array.Empty(); + } } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs index 03c78e01..431fed7b 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Threading.Tasks; diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs index 2d48a7ae..7f40e713 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs @@ -1,17 +1,35 @@ -#nullable disable +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Web.Framework.Clients.Pastebin { /// The response for a get-paste request. internal class PasteInfo { + /********* + ** Accessors + *********/ /// Whether the log was successfully fetched. - public bool Success { get; set; } + [MemberNotNullWhen(true, nameof(PasteInfo.Content))] + [MemberNotNullWhen(false, nameof(PasteInfo.Error))] + public bool Success => this.Error == null || this.Content != null; /// The fetched paste content (if is true). - public string Content { get; set; } + public string? Content { get; internal set; } - /// The error message if saving failed. - public string Error { get; set; } + /// The error message (if is false). + public string? Error { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The fetched paste content. + /// The error message, if it failed. + public PasteInfo(string? content, string? error) + { + this.Content = content; + this.Error = error; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index d0cdf374..0e00f071 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Net; using System.Threading.Tasks; @@ -35,24 +33,24 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin try { // get from API - string content = await this.Client + string? content = await this.Client .GetAsync($"raw/{id}") .AsString(); // handle Pastebin errors if (string.IsNullOrWhiteSpace(content)) - return new PasteInfo { Error = "Received an empty response from Pastebin." }; + return new PasteInfo(null, "Received an empty response from Pastebin."); if (content.StartsWith("Decompress a string. /// The compressed text. /// Derived from . - public string DecompressString(string rawText) + [return: NotNullIfNotNull("rawText")] + public string? DecompressString(string? rawText) { + if (rawText is null) + return rawText; + // get raw bytes byte[] zipBuffer; try diff --git a/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs index e1ec9b67..ef2d5696 100644 --- a/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs +++ b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs @@ -1,4 +1,4 @@ -#nullable disable +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Web.Framework.Compression { @@ -14,6 +14,7 @@ namespace StardewModdingAPI.Web.Framework.Compression /// Decompress a string. /// The compressed text. - string DecompressString(string rawText); + [return: NotNullIfNotNull("rawText")] + string? DecompressString(string? rawText); } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index 3730a9db..b582b2b0 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace StardewModdingAPI.Web.Framework.ConfigModels { /// The config settings for the API clients. @@ -12,17 +10,17 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels ** Generic ****/ /// The user agent for API clients, where {0} is the SMAPI version. - public string UserAgent { get; set; } + public string UserAgent { get; set; } = null!; /**** ** Azure ****/ /// The connection string for the Azure Blob storage account. - public string AzureBlobConnectionString { get; set; } + public string? AzureBlobConnectionString { get; set; } /// The Azure Blob container in which to store temporary uploaded logs. - public string AzureBlobTempContainer { get; set; } + public string AzureBlobTempContainer { get; set; } = null!; /// The number of days since the blob's last-modified date when it will be deleted. public int AzureBlobTempExpiryDays { get; set; } @@ -32,65 +30,65 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels ** Chucklefish ****/ /// The base URL for the Chucklefish mod site. - public string ChucklefishBaseUrl { get; set; } + public string ChucklefishBaseUrl { get; set; } = null!; /// The URL for a mod page on the Chucklefish mod site excluding the , where {0} is the mod ID. - public string ChucklefishModPageUrlFormat { get; set; } + public string ChucklefishModPageUrlFormat { get; set; } = null!; /**** ** CurseForge ****/ /// The base URL for the CurseForge API. - public string CurseForgeBaseUrl { get; set; } + public string CurseForgeBaseUrl { get; set; } = null!; /**** ** GitHub ****/ /// The base URL for the GitHub API. - public string GitHubBaseUrl { get; set; } + public string GitHubBaseUrl { get; set; } = null!; /// The Accept header value expected by the GitHub API. - public string GitHubAcceptHeader { get; set; } + public string GitHubAcceptHeader { get; set; } = null!; /// The username with which to authenticate to the GitHub API (if any). - public string GitHubUsername { get; set; } + public string? GitHubUsername { get; set; } /// The password with which to authenticate to the GitHub API (if any). - public string GitHubPassword { get; set; } + public string? GitHubPassword { get; set; } /**** ** ModDrop ****/ /// The base URL for the ModDrop API. - public string ModDropApiUrl { get; set; } + public string ModDropApiUrl { get; set; } = null!; /// The URL for a ModDrop mod page for the user, where {0} is the mod ID. - public string ModDropModPageUrl { get; set; } + public string ModDropModPageUrl { get; set; } = null!; /**** ** Nexus Mods ****/ /// The base URL for the Nexus Mods API. - public string NexusBaseUrl { get; set; } + public string NexusBaseUrl { get; set; } = null!; /// The URL for a Nexus mod page for the user, excluding the , where {0} is the mod ID. - public string NexusModUrlFormat { get; set; } + public string NexusModUrlFormat { get; set; } = null!; /// The URL for a Nexus mod page to scrape for versions, excluding the , where {0} is the mod ID. - public string NexusModScrapeUrlFormat { get; set; } + public string NexusModScrapeUrlFormat { get; set; } = null!; /// The Nexus API authentication key. - public string NexusApiKey { get; set; } + public string? NexusApiKey { get; set; } /**** ** Pastebin ****/ /// The base URL for the Pastebin API. - public string PastebinBaseUrl { get; set; } + public string PastebinBaseUrl { get; set; } = null!; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs index 682c97e6..e46ecf2b 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs @@ -1,17 +1,15 @@ -#nullable disable - namespace StardewModdingAPI.Web.Framework.ConfigModels { /// Override update-check metadata for a mod. internal class ModOverrideConfig { /// The unique ID from the mod's manifest. - public string ID { get; set; } + public string ID { get; set; } = null!; /// Whether to allow non-standard versions. public bool AllowNonStandardVersions { get; set; } /// The mod page URL to use regardless of which site has the update, or null to use the site URL. - public string SetUrl { get; set; } + public string? SetUrl { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index e525e09a..c3b136e8 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -1,4 +1,4 @@ -#nullable disable +using System; namespace StardewModdingAPI.Web.Framework.ConfigModels { @@ -8,16 +8,16 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /********* ** Accessors *********/ - /// The number of minutes successful update checks should be cached before refetching them. + /// The number of minutes successful update checks should be cached before re-fetching them. public int SuccessCacheMinutes { get; set; } - /// The number of minutes failed update checks should be cached before refetching them. + /// The number of minutes failed update checks should be cached before re-fetching them. public int ErrorCacheMinutes { get; set; } /// Update-check metadata to override. - public ModOverrideConfig[] ModOverrides { get; set; } + public ModOverrideConfig[] ModOverrides { get; set; } = Array.Empty(); /// The update-check config for SMAPI's own update checks. - public SmapiInfoConfig SmapiInfo { get; set; } + public SmapiInfoConfig SmapiInfo { get; set; } = null!; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index ef6c2659..62685e47 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace StardewModdingAPI.Web.Framework.ConfigModels { /// The site config settings. @@ -9,9 +7,9 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels ** Accessors *********/ /// A message to show below the download button (e.g. for details on downloading a beta version), in Markdown format. - public string OtherBlurb { get; set; } + public string? OtherBlurb { get; set; } /// A list of supports to credit on the main page, in Markdown format. - public string SupporterList { get; set; } + public string? SupporterList { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs index dbf58817..a95e0048 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs @@ -1,4 +1,4 @@ -#nullable disable +using System; namespace StardewModdingAPI.Web.Framework.ConfigModels { @@ -6,12 +6,12 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels internal class SmapiInfoConfig { /// The mod ID used for SMAPI update checks. - public string ID { get; set; } + public string ID { get; set; } = null!; /// The default update key used for SMAPI update checks. - public string DefaultUpdateKey { get; set; } + public string DefaultUpdateKey { get; set; } = null!; /// The update keys to add for SMAPI update checks when the player has a beta version installed. - public string[] AddBetaUpdateKeys { get; set; } + public string[] AddBetaUpdateKeys { get; set; } = Array.Empty(); } } diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs index a72c12c1..62a23155 100644 --- a/src/SMAPI.Web/Framework/Extensions.cs +++ b/src/SMAPI.Web/Framework/Extensions.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using JetBrains.Annotations; using Microsoft.AspNetCore.Html; @@ -28,7 +26,7 @@ namespace StardewModdingAPI.Web.Framework /// An object that contains route values. /// Get an absolute URL instead of a server-relative path/ /// The generated URL. - public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null, bool absoluteUrl = false) + public static string? PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object? values = null, bool absoluteUrl = false) { // get route values RouteValueDictionary valuesDict = new(values); @@ -39,7 +37,7 @@ namespace StardewModdingAPI.Web.Framework } // get relative URL - string url = helper.Action(action, controller, valuesDict); + string? url = helper.Action(action, controller, valuesDict); if (url == null && action.EndsWith("Async")) url = helper.Action(action[..^"Async".Length], controller, valuesDict); @@ -59,7 +57,7 @@ namespace StardewModdingAPI.Web.Framework /// The value to serialize. /// The serialized JSON. /// This bypasses unnecessary validation (e.g. not allowing null values) in . - public static IHtmlContent ForJson(this RazorPageBase page, object value) + public static IHtmlContent ForJson(this RazorPageBase page, object? value) { string json = JsonConvert.SerializeObject(value); return new HtmlString(json); diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs index b8d1f62c..fe171785 100644 --- a/src/SMAPI.Web/Framework/IModDownload.cs +++ b/src/SMAPI.Web/Framework/IModDownload.cs @@ -1,17 +1,18 @@ -#nullable disable - namespace StardewModdingAPI.Web.Framework { /// Generic metadata about a file download on a mod page. internal interface IModDownload { + /********* + ** Accessors + *********/ /// The download's display name. string Name { get; } /// The download's description. - string Description { get; } + string? Description { get; } /// The download's file version. - string Version { get; } + string? Version { get; } } } diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs index 68220b49..4d0a8d61 100644 --- a/src/SMAPI.Web/Framework/IModPage.cs +++ b/src/SMAPI.Web/Framework/IModPage.cs @@ -1,6 +1,5 @@ -#nullable disable - using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework @@ -18,13 +17,13 @@ namespace StardewModdingAPI.Web.Framework string Id { get; } /// The mod name. - string Name { get; } + string? Name { get; } /// The mod's semantic version number. - string Version { get; } + string? Version { get; } /// The mod's web URL. - string Url { get; } + string? Url { get; } /// The mod downloads. IModDownload[] Downloads { get; } @@ -33,7 +32,12 @@ namespace StardewModdingAPI.Web.Framework RemoteModStatus Status { get; } /// A user-friendly error which indicates why fetching the mod info failed (if applicable). - string Error { get; } + string? Error { get; } + + /// Whether the mod data is valid. + [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] + [MemberNotNullWhen(false, nameof(IModPage.Error))] + bool IsValid { get; } /********* @@ -44,7 +48,7 @@ namespace StardewModdingAPI.Web.Framework /// The mod's semantic version number. /// The mod's web URL. /// The mod downloads. - IModPage SetInfo(string name, string version, string url, IEnumerable downloads); + IModPage SetInfo(string name, string? version, string url, IEnumerable downloads); /// Set a mod fetch error. /// The mod availability status on the remote site. diff --git a/src/SMAPI.Web/Framework/InternalControllerFeatureProvider.cs b/src/SMAPI.Web/Framework/InternalControllerFeatureProvider.cs index 98738a82..2c24c610 100644 --- a/src/SMAPI.Web/Framework/InternalControllerFeatureProvider.cs +++ b/src/SMAPI.Web/Framework/InternalControllerFeatureProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Reflection; using Microsoft.AspNetCore.Mvc; diff --git a/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs index 8db43dca..3c1405eb 100644 --- a/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs +++ b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs @@ -1,5 +1,3 @@ -#nullable disable - using Hangfire.Dashboard; namespace StardewModdingAPI.Web.Framework diff --git a/src/SMAPI.Web/Framework/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs index 021d14fb..e70b60bf 100644 --- a/src/SMAPI.Web/Framework/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModInfoModel.cs @@ -1,4 +1,5 @@ -#nullable disable +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework { @@ -9,22 +10,22 @@ namespace StardewModdingAPI.Web.Framework ** Accessors *********/ /// The mod name. - public string Name { get; set; } + public string? Name { get; private set; } + + /// The mod's web URL. + public string? Url { get; private set; } /// The mod's latest version. - public ISemanticVersion Version { get; set; } + public ISemanticVersion? Version { get; private set; } /// The mod's latest optional or prerelease version, if newer than . - public ISemanticVersion PreviewVersion { get; set; } - - /// The mod's web URL. - public string Url { get; set; } + public ISemanticVersion? PreviewVersion { get; private set; } /// The mod availability status on the remote site. - public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + public RemoteModStatus Status { get; private set; } /// The error message indicating why the mod is invalid (if applicable). - public string Error { get; set; } + public string? Error { get; private set; } /********* @@ -35,19 +36,24 @@ namespace StardewModdingAPI.Web.Framework /// Construct an instance. /// The mod name. + /// The mod's web URL. /// The semantic version for the mod's latest release. /// The semantic version for the mod's latest preview release, if available and different from . - /// The mod's web URL. - public ModInfoModel(string name, ISemanticVersion version, string url, ISemanticVersion previewVersion = null) + /// The mod availability status on the remote site. + /// The error message indicating why the mod is invalid (if applicable). + [JsonConstructor] + public ModInfoModel(string name, string url, ISemanticVersion? version, ISemanticVersion? previewVersion = null, RemoteModStatus status = RemoteModStatus.Ok, string? error = null) { this .SetBasicInfo(name, url) - .SetVersions(version, previewVersion); + .SetVersions(version!, previewVersion) + .SetError(status, error!); } /// Set the basic mod info. /// The mod name. /// The mod's web URL. + [MemberNotNull(nameof(ModInfoModel.Name), nameof(ModInfoModel.Url))] public ModInfoModel SetBasicInfo(string name, string url) { this.Name = name; @@ -59,7 +65,8 @@ namespace StardewModdingAPI.Web.Framework /// Set the mod version info. /// The semantic version for the mod's latest release. /// The semantic version for the mod's latest preview release, if available and different from . - public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion previewVersion = null) + [MemberNotNull(nameof(ModInfoModel.Version))] + public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion? previewVersion = null) { this.Version = version; this.PreviewVersion = previewVersion; diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs index 2d6755d8..674b9ffc 100644 --- a/src/SMAPI.Web/Framework/ModSiteManager.cs +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -1,7 +1,6 @@ -#nullable disable - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -36,12 +35,15 @@ namespace StardewModdingAPI.Web.Framework /// The namespaced update key. public async Task GetModPageAsync(UpdateKey updateKey) { + if (!updateKey.LooksValid) + return new GenericModPage(updateKey.Site, updateKey.ID!).SetError(RemoteModStatus.DoesNotExist, $"Invalid update key '{updateKey}'."); + // get site - if (!this.ModSites.TryGetValue(updateKey.Site, out IModSiteClient client)) + if (!this.ModSites.TryGetValue(updateKey.Site, out IModSiteClient? client)) return new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Site}'. Expected one of [{string.Join(", ", this.ModSites.Keys)}]."); // fetch mod - IModPage mod; + IModPage? mod; try { mod = await client.GetModData(updateKey.ID); @@ -60,39 +62,42 @@ namespace StardewModdingAPI.Web.Framework /// The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.) /// The changes to apply to remote versions for update checks. /// Whether to allow non-standard versions. - public ModInfoModel GetPageVersions(IModPage page, string subkey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions) + public ModInfoModel GetPageVersions(IModPage page, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions) { // get base model - ModInfoModel model = new ModInfoModel() - .SetBasicInfo(page.Name, page.Url) - .SetError(page.Status, page.Error); - if (page.Status != RemoteModStatus.Ok) + ModInfoModel model = new(); + if (page.IsValid) + model.SetBasicInfo(page.Name, page.Url); + else + { + model.SetError(page.Status, page.Error); return model; + } // fetch versions - bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion mainVersion, out ISemanticVersion previewVersion); + bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion); if (!hasVersions && subkey != null) hasVersions = this.TryGetLatestVersions(page, null, allowNonStandardVersions, mapRemoteVersions, out mainVersion, out previewVersion); if (!hasVersions) return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}' has no valid versions."); // return info - return model.SetVersions(mainVersion, previewVersion); + return model.SetVersions(mainVersion!, previewVersion); } /// Get a semantic local version for update checks. /// The version to parse. /// Changes to apply to the raw version, if any. /// Whether to allow non-standard versions. - public ISemanticVersion GetMappedVersion(string version, ChangeDescriptor map, bool allowNonStandard) + public ISemanticVersion? GetMappedVersion(string? version, ChangeDescriptor? map, bool allowNonStandard) { // try mapped version - string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard); - if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion parsedNew)) + string? rawNewVersion = this.GetRawMappedVersion(version, map); + if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion? parsedNew)) return parsedNew; // return original version - return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsedOld) + return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion? parsedOld) ? parsedOld : null; } @@ -108,31 +113,31 @@ namespace StardewModdingAPI.Web.Framework /// The changes to apply to remote versions for update checks. /// The main mod version. /// The latest prerelease version, if newer than . - private bool TryGetLatestVersions(IModPage mod, string subkey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) + private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview) { main = null; preview = null; // parse all versions from the mod page - IEnumerable<(string name, string description, ISemanticVersion version)> GetAllVersions() + IEnumerable<(string? name, string? description, ISemanticVersion? version)> GetAllVersions() { if (mod != null) { - ISemanticVersion ParseAndMapVersion(string raw) + ISemanticVersion? ParseAndMapVersion(string? raw) { raw = this.NormalizeVersion(raw); return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions); } // get mod version - ISemanticVersion modVersion = ParseAndMapVersion(mod.Version); + ISemanticVersion? modVersion = ParseAndMapVersion(mod.Version); if (modVersion != null) yield return (name: null, description: null, version: ParseAndMapVersion(mod.Version)); // get file versions foreach (IModDownload download in mod.Downloads) { - ISemanticVersion cur = ParseAndMapVersion(download.Version); + ISemanticVersion? cur = ParseAndMapVersion(download.Version); if (cur != null) yield return (download.Name, download.Description, cur); } @@ -143,15 +148,15 @@ namespace StardewModdingAPI.Web.Framework .ToArray(); // get main + preview versions - void TryGetVersions(out ISemanticVersion mainVersion, out ISemanticVersion previewVersion, Func<(string name, string description, ISemanticVersion version), bool> filter = null) + void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, Func<(string? name, string? description, ISemanticVersion? version), bool>? filter = null) { mainVersion = null; previewVersion = null; // get latest main + preview version - foreach (var entry in versions) + foreach ((string? name, string? description, ISemanticVersion? version) entry in versions) { - if (filter?.Invoke(entry) == false) + if (entry.version is null || filter?.Invoke(entry) == false) continue; if (entry.version.IsPrerelease()) @@ -160,7 +165,7 @@ namespace StardewModdingAPI.Web.Framework mainVersion ??= entry.version; if (mainVersion != null) - break; // any other values will be older + break; // any others will be older since entries are sorted by version } // normalize values @@ -183,8 +188,7 @@ namespace StardewModdingAPI.Web.Framework /// Get a semantic local version for update checks. /// The version to map. /// Changes to apply to the raw version, if any. - /// Whether to allow non-standard versions. - private string GetRawMappedVersion(string version, ChangeDescriptor map, bool allowNonStandard) + private string? GetRawMappedVersion(string? version, ChangeDescriptor? map) { if (version == null || map?.HasChanges != true) return version; @@ -197,7 +201,7 @@ namespace StardewModdingAPI.Web.Framework /// Normalize a version string. /// The version to normalize. - private string NormalizeVersion(string version) + private string? NormalizeVersion(string? version) { if (string.IsNullOrWhiteSpace(version)) return null; diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs index fe601524..7b8f0ec9 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Net; using Microsoft.AspNetCore.Rewrite; @@ -13,7 +11,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules ** Fields *********/ /// Maps a lowercase hostname to the resulting redirect URL. - private readonly Func Map; + private readonly Func Map; /********* @@ -22,7 +20,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// Construct an instance. /// The status code to use for redirects. /// Hostnames mapped to the resulting redirect URL. - public RedirectHostsToUrlsRule(HttpStatusCode statusCode, Func map) + public RedirectHostsToUrlsRule(HttpStatusCode statusCode, Func map) { this.StatusCode = statusCode; this.Map = map ?? throw new ArgumentNullException(nameof(map)); @@ -35,10 +33,10 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// Get the new redirect URL. /// The rewrite context. /// Returns the redirect URL, or null if the redirect doesn't apply. - protected override string GetNewUrl(RewriteContext context) + protected override string? GetNewUrl(RewriteContext context) { // get requested host - string host = context.HttpContext.Request.Host.Host; + string? host = context.HttpContext.Request.Host.Host; // get new host host = this.Map(host); diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs index 81a265c9..b46e8f69 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Net; using Microsoft.AspNetCore.Http; @@ -24,7 +22,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// The rewrite context. public void ApplyRule(RewriteContext context) { - string newUrl = this.GetNewUrl(context); + string? newUrl = this.GetNewUrl(context); if (newUrl == null) return; @@ -41,7 +39,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// Get the new redirect URL. /// The rewrite context. /// Returns the redirect URL, or null if the redirect doesn't apply. - protected abstract string GetNewUrl(RewriteContext context); + protected abstract string? GetNewUrl(RewriteContext context); /// Get the full request URL. /// The request. diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs index cb3e53ef..e691ffba 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Collections.Generic; using System.Linq; using System.Net; @@ -39,9 +37,9 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// Get the new redirect URL. /// The rewrite context. /// Returns the redirect URL, or null if the redirect doesn't apply. - protected override string GetNewUrl(RewriteContext context) + protected override string? GetNewUrl(RewriteContext context) { - string path = context.HttpContext.Request.Path.Value; + string? path = context.HttpContext.Request.Path.Value; if (!string.IsNullOrWhiteSpace(path)) { diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs index dd7c836f..01807608 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Net; using Microsoft.AspNetCore.Http; @@ -22,7 +20,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules *********/ /// Construct an instance. /// Matches requests which should be ignored. - public RedirectToHttpsRule(Func except = null) + public RedirectToHttpsRule(Func? except = null) { this.Except = except ?? (_ => false); this.StatusCode = HttpStatusCode.RedirectKeepVerb; @@ -35,7 +33,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// Get the new redirect URL. /// The rewrite context. /// Returns the redirect URL, or null if the redirect doesn't apply. - protected override string GetNewUrl(RewriteContext context) + protected override string? GetNewUrl(RewriteContext context) { HttpRequest request = context.HttpContext.Request; if (request.IsHttps || this.Except(request)) diff --git a/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs index 2eca4845..dfc1fb47 100644 --- a/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs +++ b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Storage diff --git a/src/SMAPI.Web/Framework/Storage/StorageProvider.cs b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs index 0177e602..effbbc9f 100644 --- a/src/SMAPI.Web/Framework/Storage/StorageProvider.cs +++ b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.IO; @@ -65,11 +63,11 @@ namespace StardewModdingAPI.Web.Framework.Storage BlobClient blob = this.GetAzureBlobClient(id); await blob.UploadAsync(stream); - return new UploadResult(true, id, null); + return new UploadResult(id, null); } catch (Exception ex) { - return new UploadResult(false, null, ex.Message); + return new UploadResult(null, ex.Message); } } @@ -77,10 +75,10 @@ namespace StardewModdingAPI.Web.Framework.Storage else { string path = this.GetDevFilePath(id); - Directory.CreateDirectory(Path.GetDirectoryName(path)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, content); - return new UploadResult(true, id, null); + return new UploadResult(id, null); } } @@ -110,21 +108,15 @@ namespace StardewModdingAPI.Web.Framework.Storage string content = this.GzipHelper.DecompressString(reader.ReadToEnd()); // build model - return new StoredFileInfo - { - Success = true, - Content = content, - Expiry = expiry.UtcDateTime - }; + return new StoredFileInfo(content, expiry); } catch (RequestFailedException ex) { - return new StoredFileInfo - { - Error = ex.ErrorCode == "BlobNotFound" + return new StoredFileInfo( + error: ex.ErrorCode == "BlobNotFound" ? "There's no file with that ID." : $"Could not fetch that file from storage ({ex.ErrorCode}: {ex.Message})." - }; + ); } } @@ -137,10 +129,7 @@ namespace StardewModdingAPI.Web.Framework.Storage file.Delete(); if (!file.Exists) { - return new StoredFileInfo - { - Error = "There's no file with that ID." - }; + return new StoredFileInfo(error: "There's no file with that ID."); } // renew @@ -151,13 +140,11 @@ namespace StardewModdingAPI.Web.Framework.Storage } // build model - return new StoredFileInfo - { - Success = true, - Content = File.ReadAllText(file.FullName), - Expiry = DateTime.UtcNow.AddDays(this.ExpiryDays), - Warning = "This file was saved temporarily to the local computer. This should only happen in a local development environment." - }; + return new StoredFileInfo( + content: File.ReadAllText(file.FullName), + expiry: DateTime.UtcNow.AddDays(this.ExpiryDays), + warning: "This file was saved temporarily to the local computer. This should only happen in a local development environment." + ); } } @@ -166,12 +153,7 @@ namespace StardewModdingAPI.Web.Framework.Storage { PasteInfo response = await this.Pastebin.GetAsync(id); response.Content = this.GzipHelper.DecompressString(response.Content); - return new StoredFileInfo - { - Success = response.Success, - Content = response.Content, - Error = response.Error - }; + return new StoredFileInfo(response.Content, null, error: response.Error); } } @@ -179,8 +161,8 @@ namespace StardewModdingAPI.Web.Framework.Storage /// The file ID. private BlobClient GetAzureBlobClient(string id) { - var azure = new BlobServiceClient(this.ClientsConfig.AzureBlobConnectionString); - var container = azure.GetBlobContainerClient(this.ClientsConfig.AzureBlobTempContainer); + BlobServiceClient azure = new(this.ClientsConfig.AzureBlobConnectionString); + BlobContainerClient container = azure.GetBlobContainerClient(this.ClientsConfig.AzureBlobTempContainer); return container.GetBlobClient($"uploads/{id}"); } diff --git a/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs index cd941c94..bbbcf2a9 100644 --- a/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs +++ b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs @@ -1,25 +1,52 @@ -#nullable disable - using System; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Web.Framework.Storage { /// The response for a get-file request. internal class StoredFileInfo { + /********* + ** Accessors + *********/ /// Whether the file was successfully fetched. - public bool Success { get; set; } + [MemberNotNullWhen(true, nameof(StoredFileInfo.Content))] + public bool Success => this.Content != null && this.Error == null; /// The fetched file content (if is true). - public string Content { get; set; } + public string? Content { get; } /// When the file will no longer be available. - public DateTime? Expiry { get; set; } + public DateTimeOffset? Expiry { get; } /// The error message if saving succeeded, but a non-blocking issue was encountered. - public string Warning { get; set; } + public string? Warning { get; } /// The error message if saving failed. - public string Error { get; set; } + public string? Error { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The fetched file content (if is true). + /// When the file will no longer be available. + /// The error message if saving succeeded, but a non-blocking issue was encountered. + /// The error message if saving failed. + public StoredFileInfo(string? content, DateTimeOffset? expiry, string? warning = null, string? error = null) + { + this.Content = content; + this.Expiry = expiry; + this.Warning = warning; + this.Error = error; + } + + /// Construct an instance. + /// The error message if saving failed. + public StoredFileInfo(string error) + { + this.Error = error; + } } } diff --git a/src/SMAPI.Web/Framework/Storage/UploadResult.cs b/src/SMAPI.Web/Framework/Storage/UploadResult.cs index b1eedd59..92993d42 100644 --- a/src/SMAPI.Web/Framework/Storage/UploadResult.cs +++ b/src/SMAPI.Web/Framework/Storage/UploadResult.cs @@ -1,4 +1,4 @@ -#nullable disable +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Web.Framework.Storage { @@ -9,25 +9,25 @@ namespace StardewModdingAPI.Web.Framework.Storage ** Accessors *********/ /// Whether the file upload succeeded. - public bool Succeeded { get; } + [MemberNotNullWhen(true, nameof(UploadResult.ID))] + [MemberNotNullWhen(false, nameof(UploadResult.UploadError))] + public bool Succeeded => this.ID != null && this.UploadError == null; /// The file ID, if applicable. - public string ID { get; } + public string? ID { get; } /// The upload error, if any. - public string UploadError { get; } + public string? UploadError { get; } /********* ** Public methods *********/ /// Construct an instance. - /// Whether the file upload succeeded. /// The file ID, if applicable. /// The upload error, if any. - public UploadResult(bool succeeded, string id, string uploadError) + public UploadResult(string? id, string? uploadError) { - this.Succeeded = succeeded; this.ID = id; this.UploadError = uploadError; } diff --git a/src/SMAPI.Web/Framework/VersionConstraint.cs b/src/SMAPI.Web/Framework/VersionConstraint.cs index f230a95b..1b1abd81 100644 --- a/src/SMAPI.Web/Framework/VersionConstraint.cs +++ b/src/SMAPI.Web/Framework/VersionConstraint.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -20,7 +18,7 @@ namespace StardewModdingAPI.Web.Framework /// A dictionary that contains the parameters for the URL. /// An object that indicates whether the constraint check is being performed when an incoming request is being handled or when a URL is being generated. /// true if the URL parameter contains a valid value; otherwise, false. - public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) + public bool Match(HttpContext? httpContext, IRouter? route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) { if (routeKey == null) throw new ArgumentNullException(nameof(routeKey)); @@ -28,7 +26,7 @@ namespace StardewModdingAPI.Web.Framework throw new ArgumentNullException(nameof(values)); return - values.TryGetValue(routeKey, out object routeValue) + values.TryGetValue(routeKey, out object? routeValue) && routeValue is string routeStr && SemanticVersion.TryParse(routeStr, allowNonStandard: true, out _); } diff --git a/src/SMAPI.Web/Program.cs b/src/SMAPI.Web/Program.cs index 5134791a..1fdd3185 100644 --- a/src/SMAPI.Web/Program.cs +++ b/src/SMAPI.Web/Program.cs @@ -1,5 +1,3 @@ -#nullable disable - using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 0199938d..98dbca5e 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -1,5 +1,3 @@ -#nullable disable - using System.Collections.Generic; using System.Net; using Hangfire; @@ -102,7 +100,7 @@ namespace StardewModdingAPI.Web // init API clients { ApiClientsConfig api = this.Configuration.GetSection("ApiClients").Get(); - string version = this.GetType().Assembly.GetName().Version.ToString(3); + string version = this.GetType().Assembly.GetName().Version!.ToString(3); string userAgent = string.Format(api.UserAgent, version); services.AddSingleton(new ChucklefishClient( diff --git a/src/SMAPI.Web/ViewModels/IndexModel.cs b/src/SMAPI.Web/ViewModels/IndexModel.cs index 2283acd9..098f18cc 100644 --- a/src/SMAPI.Web/ViewModels/IndexModel.cs +++ b/src/SMAPI.Web/ViewModels/IndexModel.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace StardewModdingAPI.Web.ViewModels { /// The view model for the index page. @@ -9,26 +7,23 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// The latest stable SMAPI version. - public IndexVersionModel StableVersion { get; set; } + public IndexVersionModel StableVersion { get; } /// A message to show below the download button (e.g. for details on downloading a beta version), in Markdown format. - public string OtherBlurb { get; set; } + public string? OtherBlurb { get; } /// A list of supports to credit on the main page, in Markdown format. - public string SupporterList { get; set; } + public string? SupporterList { get; } /********* ** Public methods *********/ - /// Construct an instance. - public IndexModel() { } - /// Construct an instance. /// The latest stable SMAPI version. /// A message to show below the download button (e.g. for details on downloading a beta version), in Markdown format. /// A list of supports to credit on the main page, in Markdown format. - internal IndexModel(IndexVersionModel stableVersion, string otherBlurb, string supporterList) + internal IndexModel(IndexVersionModel stableVersion, string? otherBlurb, string? supporterList) { this.StableVersion = stableVersion; this.OtherBlurb = otherBlurb; diff --git a/src/SMAPI.Web/ViewModels/IndexVersionModel.cs b/src/SMAPI.Web/ViewModels/IndexVersionModel.cs index 1f5d4ec0..a76a5924 100644 --- a/src/SMAPI.Web/ViewModels/IndexVersionModel.cs +++ b/src/SMAPI.Web/ViewModels/IndexVersionModel.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace StardewModdingAPI.Web.ViewModels { /// The fields for a SMAPI version. @@ -9,30 +7,27 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// The release version. - public string Version { get; set; } + public string Version { get; } /// The Markdown description for the release. - public string Description { get; set; } + public string Description { get; } /// The main download URL. - public string DownloadUrl { get; set; } + public string DownloadUrl { get; } /// The for-developers download URL (not applicable for prerelease versions). - public string DevDownloadUrl { get; set; } + public string? DevDownloadUrl { get; } /********* ** Public methods *********/ - /// Construct an instance. - public IndexVersionModel() { } - /// Construct an instance. /// The release number. /// The Markdown description for the release. /// The main download URL. /// The for-developers download URL (not applicable for prerelease versions). - internal IndexVersionModel(string version, string description, string downloadUrl, string devDownloadUrl) + internal IndexVersionModel(string version, string description, string downloadUrl, string? devDownloadUrl) { this.Version = version; this.Description = description; diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs index 3c63b730..4d37d449 100644 --- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs @@ -1,5 +1,3 @@ -#nullable disable - using Newtonsoft.Json.Schema; namespace StardewModdingAPI.Web.ViewModels.JsonValidator @@ -11,30 +9,27 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator ** Accessors *********/ /// The line number on which the error occurred. - public int Line { get; set; } + public int Line { get; } /// The field path in the JSON file where the error occurred. - public string Path { get; set; } + public string? Path { get; } /// A human-readable description of the error. - public string Message { get; set; } + public string Message { get; } /// The schema error type. - public ErrorType SchemaErrorType { get; set; } + public ErrorType SchemaErrorType { get; } /********* ** Public methods *********/ - /// Construct an instance. - public JsonValidatorErrorModel() { } - /// Construct an instance. /// The line number on which the error occurred. /// The field path in the JSON file where the error occurred. /// A human-readable description of the error. /// The schema error type. - public JsonValidatorErrorModel(int line, string path, string message, ErrorType schemaErrorType) + public JsonValidatorErrorModel(int line, string? path, string message, ErrorType schemaErrorType) { this.Line = line; this.Path = path; diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs index 2543807f..85c2f44d 100644 --- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -13,51 +11,48 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator ** Accessors *********/ /// Whether to show the edit view. - public bool IsEditView { get; set; } + public bool IsEditView { get; } /// The paste ID. - public string PasteID { get; set; } + public string? PasteID { get; } /// The schema name with which the JSON was validated. - public string SchemaName { get; set; } + public string? SchemaName { get; } /// The supported JSON schemas (names indexed by ID). - public readonly IDictionary SchemaFormats; + public IDictionary SchemaFormats { get; } /// The validated content. - public string Content { get; set; } + public string? Content { get; set; } /// The schema validation errors, if any. public JsonValidatorErrorModel[] Errors { get; set; } = Array.Empty(); /// A non-blocking warning while uploading the file. - public string UploadWarning { get; set; } + public string? UploadWarning { get; set; } /// When the uploaded file will no longer be available. - public DateTime? Expiry { get; set; } + public DateTimeOffset? Expiry { get; set; } /// An error which occurred while uploading the JSON. - public string UploadError { get; set; } + public string? UploadError { get; set; } /// An error which occurred while parsing the JSON. - public string ParseError { get; set; } + public string? ParseError { get; set; } /// A web URL to the user-facing format documentation. - public string FormatUrl { get; set; } + public string? FormatUrl { get; set; } /********* ** Public methods *********/ - /// Construct an instance. - public JsonValidatorModel() { } - /// Construct an instance. /// The stored file ID. /// The schema name with which the JSON was validated. /// The supported JSON schemas (names indexed by ID). /// Whether to show the edit view. - public JsonValidatorModel(string pasteID, string schemaName, IDictionary schemaFormats, bool isEditView) + public JsonValidatorModel(string? pasteID, string? schemaName, IDictionary schemaFormats, bool isEditView) { this.PasteID = pasteID; this.SchemaName = schemaName; @@ -69,7 +64,7 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator /// The validated content. /// When the uploaded file will no longer be available. /// A non-blocking warning while uploading the log. - public JsonValidatorModel SetContent(string content, DateTime? expiry, string uploadWarning = null) + public JsonValidatorModel SetContent(string content, DateTimeOffset? expiry, string? uploadWarning = null) { this.Content = content; this.Expiry = expiry; diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs index 43114d94..3edb58db 100644 --- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace StardewModdingAPI.Web.ViewModels.JsonValidator { /// The view model for a JSON validation request. @@ -9,9 +7,22 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator ** Accessors *********/ /// The schema name with which to validate the JSON. - public string SchemaName { get; set; } + public string SchemaName { get; } /// The raw content to validate. - public string Content { get; set; } + public string Content { get; } + + + /********* + ** Accessors + *********/ + /// Construct an instance. + /// The schema name with which to validate the JSON. + /// The raw content to validate. + public JsonValidatorRequestModel(string schemaName, string content) + { + this.SchemaName = schemaName; + this.Content = content; + } } } diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs index d7e4d810..c39a9b0a 100644 --- a/src/SMAPI.Web/ViewModels/LogParserModel.cs +++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs @@ -44,7 +44,7 @@ namespace StardewModdingAPI.Web.ViewModels public string? ParseError => this.ParsedLog?.Error; /// When the uploaded file will no longer be available. - public DateTime? Expiry { get; set; } + public DateTimeOffset? Expiry { get; set; } /// Whether parsed log data is available. [MemberNotNullWhen(true, nameof(LogParserModel.PasteID), nameof(LogParserModel.ParsedLog))] diff --git a/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs b/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs index 2af30cc3..36ea891d 100644 --- a/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs +++ b/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs @@ -1,5 +1,4 @@ -#nullable disable - +using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.ViewModels @@ -11,21 +10,35 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// The compatibility status, as a string like "Broken". - public string Status { get; set; } + public string Status { get; } /// The human-readable summary, as an HTML block. - public string Summary { get; set; } + public string? Summary { get; } /// The game or SMAPI version which broke this mod (if applicable). - public string BrokeIn { get; set; } + public string? BrokeIn { get; } /// A link to the unofficial version which fixes compatibility, if any. - public ModLinkModel UnofficialVersion { get; set; } + public ModLinkModel? UnofficialVersion { get; } /********* ** Public methods *********/ + /// Construct an instance. + /// The compatibility status, as a string like "Broken". + /// The human-readable summary, as an HTML block. + /// The game or SMAPI version which broke this mod (if applicable). + /// A link to the unofficial version which fixes compatibility, if any. + [JsonConstructor] + public ModCompatibilityModel(string status, string? summary, string? brokeIn, ModLinkModel? unofficialVersion) + { + this.Status = status; + this.Summary = summary; + this.BrokeIn = brokeIn; + this.UnofficialVersion = unofficialVersion; + } + /// Construct an instance. /// The mod metadata. public ModCompatibilityModel(WikiCompatibilityInfo info) @@ -36,7 +49,7 @@ namespace StardewModdingAPI.Web.ViewModels this.Summary = info.Summary; this.BrokeIn = info.BrokeIn; if (info.UnofficialVersion != null) - this.UnofficialVersion = new ModLinkModel(info.UnofficialUrl, info.UnofficialVersion.ToString()); + this.UnofficialVersion = new ModLinkModel(info.UnofficialUrl!, info.UnofficialVersion.ToString()); } } } diff --git a/src/SMAPI.Web/ViewModels/ModLinkModel.cs b/src/SMAPI.Web/ViewModels/ModLinkModel.cs index 3039702e..96f14d48 100644 --- a/src/SMAPI.Web/ViewModels/ModLinkModel.cs +++ b/src/SMAPI.Web/ViewModels/ModLinkModel.cs @@ -1,5 +1,3 @@ -#nullable disable - namespace StardewModdingAPI.Web.ViewModels { /// Metadata about a link. @@ -9,10 +7,10 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// The URL of the linked page. - public string Url { get; set; } + public string Url { get; } /// The suggested link text. - public string Text { get; set; } + public string Text { get; } /********* diff --git a/src/SMAPI.Web/ViewModels/ModListModel.cs b/src/SMAPI.Web/ViewModels/ModListModel.cs index f0cf0c3a..be9f973a 100644 --- a/src/SMAPI.Web/ViewModels/ModListModel.cs +++ b/src/SMAPI.Web/ViewModels/ModListModel.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -13,37 +11,34 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// The current stable version of the game. - public string StableVersion { get; set; } + public string? StableVersion { get; } /// The current beta version of the game (if any). - public string BetaVersion { get; set; } + public string? BetaVersion { get; } /// The mods to display. - public ModModel[] Mods { get; set; } + public ModModel[] Mods { get; } /// When the data was last updated. - public DateTimeOffset LastUpdated { get; set; } + public DateTimeOffset LastUpdated { get; } /// Whether the data hasn't been updated in a while. - public bool IsStale { get; set; } + public bool IsStale { get; } /// Whether the mod metadata is available. - public bool HasData => this.Mods?.Any() == true; + public bool HasData => this.Mods.Any(); /********* ** Public methods *********/ - /// Construct an empty instance. - public ModListModel() { } - /// Construct an instance. /// The current stable version of the game. /// The current beta version of the game (if any). /// The mods to display. /// When the data was last updated. /// Whether the data hasn't been updated in a while. - public ModListModel(string stableVersion, string betaVersion, IEnumerable mods, DateTimeOffset lastUpdated, bool isStale) + public ModListModel(string? stableVersion, string? betaVersion, IEnumerable mods, DateTimeOffset lastUpdated, bool isStale) { this.StableVersion = stableVersion; this.BetaVersion = betaVersion; diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs index d0d7373b..929bf682 100644 --- a/src/SMAPI.Web/ViewModels/ModModel.cs +++ b/src/SMAPI.Web/ViewModels/ModModel.cs @@ -1,7 +1,6 @@ -#nullable disable - using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.ViewModels @@ -13,43 +12,43 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// The mod name. - public string Name { get; set; } + public string? Name { get; } /// The mod's alternative names, if any. - public string AlternateNames { get; set; } + public string AlternateNames { get; } /// The mod author's name. - public string Author { get; set; } + public string? Author { get; } /// The mod author's alternative names, if any. - public string AlternateAuthors { get; set; } + public string AlternateAuthors { get; } /// The GitHub repo, if any. - public string GitHubRepo { get; set; } + public string? GitHubRepo { get; } /// The URL to the mod's source code, if any. - public string SourceUrl { get; set; } + public string? SourceUrl { get; } /// The compatibility status for the stable version of the game. - public ModCompatibilityModel Compatibility { get; set; } + public ModCompatibilityModel Compatibility { get; } /// The compatibility status for the beta version of the game. - public ModCompatibilityModel BetaCompatibility { get; set; } + public ModCompatibilityModel? BetaCompatibility { get; } /// Links to the available mod pages. - public ModLinkModel[] ModPages { get; set; } + public ModLinkModel[] ModPages { get; } /// The human-readable warnings for players about this mod. - public string[] Warnings { get; set; } + public string[] Warnings { get; } /// The URL of the pull request which submits changes for an unofficial update to the author, if any. - public string PullRequestUrl { get; set; } + public string? PullRequestUrl { get; } - /// Special notes intended for developers who maintain unofficial updates or submit pull requests. - public string DevNote { get; set; } + /// Special notes intended for developers who maintain unofficial updates or submit pull requests. + public string? DevNote { get; } /// A unique identifier for the mod that can be used in an anchor URL. - public string Slug { get; set; } + public string? Slug { get; } /// The sites where the mod can be downloaded. public string[] ModPageSites => this.ModPages.Select(p => p.Text).ToArray(); @@ -58,6 +57,38 @@ namespace StardewModdingAPI.Web.ViewModels /********* ** Public methods *********/ + /// Construct an instance. + /// The mod name. + /// The mod's alternative names, if any. + /// The mod author's name. + /// The mod author's alternative names, if any. + /// The GitHub repo, if any. + /// The URL to the mod's source code, if any. + /// The compatibility status for the stable version of the game. + /// The compatibility status for the beta version of the game. + /// Links to the available mod pages. + /// The human-readable warnings for players about this mod. + /// The URL of the pull request which submits changes for an unofficial update to the author, if any. + /// Special notes intended for developers who maintain unofficial updates or submit pull requests. + /// A unique identifier for the mod that can be used in an anchor URL. + [JsonConstructor] + public ModModel(string? name, string alternateNames, string author, string alternateAuthors, string gitHubRepo, string sourceUrl, ModCompatibilityModel compatibility, ModCompatibilityModel betaCompatibility, ModLinkModel[] modPages, string[] warnings, string pullRequestUrl, string devNote, string slug) + { + this.Name = name; + this.AlternateNames = alternateNames; + this.Author = author; + this.AlternateAuthors = alternateAuthors; + this.GitHubRepo = gitHubRepo; + this.SourceUrl = sourceUrl; + this.Compatibility = compatibility; + this.BetaCompatibility = betaCompatibility; + this.ModPages = modPages; + this.Warnings = warnings; + this.PullRequestUrl = pullRequestUrl; + this.DevNote = devNote; + this.Slug = slug; + } + /// Construct an instance. /// The mod metadata. public ModModel(WikiModEntry entry) @@ -84,7 +115,7 @@ namespace StardewModdingAPI.Web.ViewModels *********/ /// Get the web URL for the mod's source code repository, if any. /// The mod metadata. - private string GetSourceUrl(WikiModEntry entry) + private string? GetSourceUrl(WikiModEntry entry) { if (!string.IsNullOrWhiteSpace(entry.GitHubRepo)) return $"https://github.com/{entry.GitHubRepo}"; diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index 9841ca42..acb8df78 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -1,7 +1,3 @@ -@{ - #nullable disable -} - @using Microsoft.Extensions.Options @using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework.ConfigModels @@ -28,7 +24,7 @@
    - Download SMAPI @Model.StableVersion.Version
    + Download SMAPI @Model.StableVersion.Version