diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2021-10-02 16:40:23 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2021-10-02 16:40:23 -0400 |
commit | b5c88d87d2cb1739585651e02513fef73dfc0e27 (patch) | |
tree | cd8a2afda5ee8cccc806574613f6de4073970c7a /src | |
parent | 0888f71a5c7fe2bbf815409a70834ac85013c7f8 (diff) | |
download | SMAPI-b5c88d87d2cb1739585651e02513fef73dfc0e27.tar.gz SMAPI-b5c88d87d2cb1739585651e02513fef73dfc0e27.tar.bz2 SMAPI-b5c88d87d2cb1739585651e02513fef73dfc0e27.zip |
add support for unified mod data overrides on the wiki
Diffstat (limited to 'src')
9 files changed, 478 insertions, 111 deletions
diff --git a/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs b/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs new file mode 100644 index 00000000..b896b09c --- /dev/null +++ b/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using StardewModdingAPI; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace SMAPI.Tests.WikiClient +{ + /// <summary>Unit tests for <see cref="ChangeDescriptor"/>.</summary> + [TestFixture] + internal class ChangeDescriptorTests + { + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = "Assert that Parse sets the expected values for valid and invalid descriptors.")] + public void Parse_SetsExpectedValues_Raw() + { + // arrange + string rawDescriptor = "-Nexus:2400, -B, XX → YY, Nexus:451,+A, XXX → YYY, invalidA →, → invalidB"; + string[] expectedAdd = new[] { "Nexus:451", "A" }; + string[] expectedRemove = new[] { "Nexus:2400", "B" }; + IDictionary<string, string> expectedReplace = new Dictionary<string, string> + { + ["XX"] = "YY", + ["XXX"] = "YYY" + }; + string[] expectedErrors = new[] + { + "Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.", + "Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value." + }; + + // act + ChangeDescriptor parsed = ChangeDescriptor.Parse(rawDescriptor, out string[] errors); + + // assert + Assert.That(parsed.Add, Is.EquivalentTo(expectedAdd), $"{nameof(parsed.Add)} doesn't match the expected value."); + Assert.That(parsed.Remove, Is.EquivalentTo(expectedRemove), $"{nameof(parsed.Replace)} doesn't match the expected value."); + Assert.That(parsed.Replace, Is.EquivalentTo(expectedReplace), $"{nameof(parsed.Replace)} doesn't match the expected value."); + Assert.That(errors, Is.EquivalentTo(expectedErrors), $"{nameof(errors)} doesn't match the expected value."); + } + + [Test(Description = "Assert that Parse sets the expected values for descriptors when a format callback is specified.")] + public void Parse_SetsExpectedValues_Formatted() + { + // arrange + string rawDescriptor = "-1.0.1, -2.0-beta, 1.00 → 1.0, 1.0.0,+2.0-beta.15, 2.0 → 2.0-beta, invalidA →, → invalidB"; + string[] expectedAdd = new[] { "1.0.0", "2.0.0-beta.15" }; + string[] expectedRemove = new[] { "1.0.1", "2.0.0-beta" }; + IDictionary<string, string> expectedReplace = new Dictionary<string, string> + { + ["1.00"] = "1.0.0", + ["2.0.0"] = "2.0.0-beta" + }; + string[] expectedErrors = new[] + { + "Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.", + "Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value." + }; + + // act + ChangeDescriptor parsed = ChangeDescriptor.Parse( + rawDescriptor, + out string[] errors, + formatValue: raw => SemanticVersion.TryParse(raw, out ISemanticVersion version) + ? version.ToString() + : raw + ); + + // assert + Assert.That(parsed.Add, Is.EquivalentTo(expectedAdd), $"{nameof(parsed.Add)} doesn't match the expected value."); + Assert.That(parsed.Remove, Is.EquivalentTo(expectedRemove), $"{nameof(parsed.Replace)} doesn't match the expected value."); + Assert.That(parsed.Replace, Is.EquivalentTo(expectedReplace), $"{nameof(parsed.Replace)} doesn't match the expected value."); + Assert.That(errors, Is.EquivalentTo(expectedErrors), $"{nameof(errors)} doesn't match the expected value."); + } + + [Test(Description = "Assert that Apply returns the expected value for the given descriptor.")] + + // null input + [TestCase(null, "", ExpectedResult = null)] + [TestCase(null, "+Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase(null, "-Nexus:2400", ExpectedResult = null)] + + // blank input + [TestCase("", null, ExpectedResult = "")] + [TestCase("", "", ExpectedResult = "")] + + // add value + [TestCase("", "+Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase("Nexus:2400", "+Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase("Nexus:2400", "Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase("Nexus:2400", "+Nexus:2401", ExpectedResult = "Nexus:2400, Nexus:2401")] + [TestCase("Nexus:2400", "Nexus:2401", ExpectedResult = "Nexus:2400, Nexus:2401")] + + // remove value + [TestCase("", "-Nexus:2400", ExpectedResult = "")] + [TestCase("Nexus:2400", "-Nexus:2400", ExpectedResult = "")] + [TestCase("Nexus:2400", "-Nexus:2401", ExpectedResult = "Nexus:2400")] + + // replace value + [TestCase("", "Nexus:2400 → Nexus:2401", ExpectedResult = "")] + [TestCase("Nexus:2400", "Nexus:2400 → Nexus:2401", ExpectedResult = "Nexus:2401")] + [TestCase("Nexus:1", "Nexus: 2400 → Nexus: 2401", ExpectedResult = "Nexus:1")] + + // complex strings + [TestCase("", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:A, Nexus:B")] + [TestCase("Nexus:2400", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:A, Nexus:B")] + [TestCase("Nexus:2400, Nexus:2401, Nexus:B,Chucklefish:14", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:2401, Nexus:B, Nexus:A")] + public string Apply_Raw(string input, string descriptor) + { + var parsed = ChangeDescriptor.Parse(descriptor, out string[] errors); + + Assert.IsEmpty(errors, "Parsing the descriptor failed."); + + return parsed.ApplyToCopy(input); + } + + [Test(Description = "Assert that ToString returns the expected normalized descriptors.")] + [TestCase(null, ExpectedResult = "")] + [TestCase("", ExpectedResult = "")] + [TestCase("+ Nexus:2400", ExpectedResult = "+Nexus:2400")] + [TestCase(" Nexus:2400 ", ExpectedResult = "+Nexus:2400")] + [TestCase("-Nexus:2400", ExpectedResult = "-Nexus:2400")] + [TestCase(" Nexus:2400 →Nexus:2401 ", ExpectedResult = "Nexus:2400 → Nexus:2401")] + [TestCase("+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "+Nexus:A, +Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A → Nexus:B")] + public string ToString(string descriptor) + { + var parsed = ChangeDescriptor.Parse(descriptor, out string[] errors); + + Assert.IsEmpty(errors, "Parsing the descriptor failed."); + + return parsed.ToString(); + } + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs index 8c21e4e0..5945ff6e 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -87,11 +86,14 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /**** ** Version mappings ****/ - /// <summary>Maps local versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapLocalVersions { get; set; } + /// <summary>A serialized change descriptor to apply to the local version during update checks (see <see cref="ChangeDescriptor"/>).</summary> + public string ChangeLocalVersions { get; set; } - /// <summary>Maps remote versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapRemoteVersions { get; set; } + /// <summary>A serialized change descriptor to apply to the remote version during update checks (see <see cref="ChangeDescriptor"/>).</summary> + public string ChangeRemoteVersions { get; set; } + + /// <summary>A serialized change descriptor to apply to the update keys during update checks (see <see cref="ChangeDescriptor"/>).</summary> + public string ChangeUpdateKeys { get; set; } /********* @@ -137,8 +139,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary; this.BetaBrokeIn = wiki.BetaCompatibility?.BrokeIn; - this.MapLocalVersions = wiki.MapLocalVersions; - this.MapRemoteVersions = wiki.MapRemoteVersions; + this.ChangeLocalVersions = wiki.Overrides?.ChangeLocalVersions?.ToString(); + this.ChangeRemoteVersions = wiki.Overrides?.ChangeRemoteVersions?.ToString(); + this.ChangeUpdateKeys = wiki.Overrides?.ChangeUpdateKeys?.ToString(); } // internal DB data @@ -148,16 +151,5 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi this.Name ??= db.DisplayName; } } - - /// <summary>Get update keys based on the metadata.</summary> - public IEnumerable<string> GetUpdateKeys() - { - if (this.NexusID.HasValue) - yield return $"Nexus:{this.NexusID}"; - if (this.ChucklefishID.HasValue) - yield return $"Chucklefish:{this.ChucklefishID}"; - if (this.GitHubRepo != null) - yield return $"GitHub:{this.GitHubRepo}"; - } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs new file mode 100644 index 00000000..6fcc3b18 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>A set of changes which can be applied to a mod data field.</summary> + public class ChangeDescriptor + { + /********* + ** Accessors + *********/ + /// <summary>The values to add to the field.</summary> + public ISet<string> Add { get; } + + /// <summary>The values to remove from the field.</summary> + public ISet<string> Remove { get; } + + /// <summary>The values to replace in the field, if matched.</summary> + public IReadOnlyDictionary<string, string> Replace { get; } + + /// <summary>Whether the change descriptor would make any changes.</summary> + public bool HasChanges { get; } + + /// <summary>Format a raw value into a normalized form.</summary> + public Func<string, string> FormatValue { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="add">The values to add to the field.</param> + /// <param name="remove">The values to remove from the field.</param> + /// <param name="replace">The values to replace in the field, if matched.</param> + /// <param name="formatValue">Format a raw value into a normalized form.</param> + public ChangeDescriptor(ISet<string> add, ISet<string> remove, IReadOnlyDictionary<string, string> replace, Func<string, string> formatValue) + { + this.Add = add; + this.Remove = remove; + this.Replace = replace; + this.HasChanges = add.Any() || remove.Any() || replace.Any(); + this.FormatValue = formatValue; + } + + /// <summary>Apply the change descriptors to a comma-delimited field.</summary> + /// <param name="rawField">The raw field text.</param> + /// <returns>Returns the modified field.</returns> + public string ApplyToCopy(string rawField) + { + // get list + List<string> values = !string.IsNullOrWhiteSpace(rawField) + ? new List<string>(rawField.Split(',')) + : new List<string>(); + + // apply changes + this.Apply(values); + + // format + if (rawField == null && !values.Any()) + return null; + return string.Join(", ", values); + } + + /// <summary>Apply the change descriptors to the given field values.</summary> + /// <param name="values">The field values.</param> + /// <returns>Returns the modified field values.</returns> + public void Apply(List<string> values) + { + // replace/remove values + if (this.Replace.Any() || this.Remove.Any()) + { + for (int i = values.Count - 1; i >= 0; i--) + { + string value = this.FormatValue(values[i]?.Trim() ?? string.Empty); + + if (this.Remove.Contains(value)) + values.RemoveAt(i); + + else if (this.Replace.TryGetValue(value, out string newValue)) + values[i] = newValue; + } + } + + // add values + if (this.Add.Any()) + { + HashSet<string> curValues = new(values.Select(p => p?.Trim() ?? string.Empty), StringComparer.OrdinalIgnoreCase); + foreach (string add in this.Add) + { + if (!curValues.Contains(add)) + { + values.Add(add); + curValues.Add(add); + } + } + } + } + + /// <inheritdoc /> + public override string ToString() + { + if (!this.HasChanges) + return string.Empty; + + List<string> descriptors = new List<string>(this.Add.Count + this.Remove.Count + this.Replace.Count); + foreach (string add in this.Add) + descriptors.Add($"+{add}"); + foreach (string remove in this.Remove) + descriptors.Add($"-{remove}"); + foreach (var pair in this.Replace) + descriptors.Add($"{pair.Key} → {pair.Value}"); + + return string.Join(", ", descriptors); + } + + /// <summary>Parse a raw change descriptor string into a <see cref="ChangeDescriptor"/> model.</summary> + /// <param name="descriptor">The raw change descriptor.</param> + /// <param name="errors">The human-readable error message describing any invalid values that were ignored.</param> + /// <param name="formatValue">Format a raw value into a normalized form if needed.</param> + public static ChangeDescriptor Parse(string descriptor, out string[] errors, Func<string, string> formatValue = null) + { + // init + formatValue ??= p => p; + var add = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var remove = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var replace = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + // parse each change in the descriptor + if (!string.IsNullOrWhiteSpace(descriptor)) + { + List<string> rawErrors = new(); + foreach (string rawEntry in descriptor.Split(',')) + { + // normalzie entry + string entry = rawEntry.Trim(); + if (entry == string.Empty) + continue; + + // parse as replace (old value → new value) + if (entry.Contains('→')) + { + string[] parts = entry.Split(new[] { '→' }, 2); + string oldValue = formatValue(parts[0].Trim()); + string newValue = formatValue(parts[1].Trim()); + + if (oldValue == string.Empty) + { + rawErrors.Add($"Failed parsing '{rawEntry}': can't map from a blank old value. Use the '+value' format to add a value."); + continue; + } + + if (newValue == string.Empty) + { + rawErrors.Add($"Failed parsing '{rawEntry}': can't map to a blank value. Use the '-value' format to remove a value."); + continue; + } + + replace[oldValue] = newValue; + } + + // else as remove + else if (entry.StartsWith("-")) + { + entry = formatValue(entry.Substring(1).Trim()); + remove.Add(entry); + } + + // else as add + else + { + if (entry.StartsWith("+")) + entry = formatValue(entry.Substring(1).Trim()); + add.Add(entry); + } + } + + errors = rawErrors.ToArray(); + } + else + errors = Array.Empty<string>(); + + // build model + return new ChangeDescriptor( + add: add, + remove: remove, + replace: new ReadOnlyDictionary<string, string>(replace), + formatValue: formatValue + ); + } + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs index da312471..f48c4ffa 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -6,6 +6,7 @@ using System.Net; using System.Threading.Tasks; using HtmlAgilityPack; using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { @@ -55,13 +56,33 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki if (betaVersion == stableVersion) betaVersion = null; - // find mod entries - HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("//table[@id='mod-list']//tr[@class='mod']"); - if (modNodes == null) - throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); + // parse mod data overrides + Dictionary<string, WikiDataOverrideEntry> overrides = new(StringComparer.OrdinalIgnoreCase); + { + HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("//table[@id='mod-overrides-list']//tr[@class='mod']"); + 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)) + { + if (entry.Ids?.Any() != true || !entry.HasChanges) + continue; + + foreach (string id in entry.Ids) + overrides[id] = entry; + } + } + + // parse mod entries + WikiModEntry[] mods; + { + HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("//table[@id='mod-list']//tr[@class='mod']"); + if (modNodes == null) + throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); + mods = this.ParseModEntries(modNodes, overrides).ToArray(); + } - // parse - WikiModEntry[] mods = this.ParseEntries(modNodes).ToArray(); + // build model return new WikiModList { StableVersion = stableVersion, @@ -82,7 +103,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki *********/ /// <summary>Parse valid mod compatibility entries.</summary> /// <param name="nodes">The HTML compatibility entries.</param> - private IEnumerable<WikiModEntry> ParseEntries(IEnumerable<HtmlNode> nodes) + /// <param name="overridesById">The mod data overrides to apply, if any.</param> + private IEnumerable<WikiModEntry> ParseModEntries(IEnumerable<HtmlNode> nodes, IDictionary<string, WikiDataOverrideEntry> overridesById) { foreach (HtmlNode node in nodes) { @@ -103,9 +125,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki string contentPackFor = this.GetAttribute(node, "data-content-pack-for"); string devNote = this.GetAttribute(node, "data-dev-note"); string pullRequestUrl = this.GetAttribute(node, "data-pr"); - IDictionary<string, string> mapLocalVersions = this.GetAttributeAsVersionMapping(node, "data-map-local-versions"); - IDictionary<string, string> mapRemoteVersions = this.GetAttributeAsVersionMapping(node, "data-map-remote-versions"); - string[] changeUpdateKeys = this.GetAttributeAsCsv(node, "data-change-update-keys"); // parse stable compatibility WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo @@ -134,6 +153,11 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki } } + // find data overrides + WikiDataOverrideEntry overrides = ids + .Select(id => overridesById.TryGetValue(id, out overrides) ? overrides : null) + .FirstOrDefault(p => p != null); + // yield model yield return new WikiModEntry { @@ -154,14 +178,35 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki Warnings = warnings, PullRequestUrl = pullRequestUrl, DevNote = devNote, - ChangeUpdateKeys = changeUpdateKeys, - MapLocalVersions = mapLocalVersions, - MapRemoteVersions = mapRemoteVersions, + Overrides = overrides, Anchor = anchor }; } } + /// <summary>Parse valid mod data override entries.</summary> + /// <param name="nodes">The HTML mod data override entries.</param> + private IEnumerable<WikiDataOverrideEntry> ParseOverrideEntries(IEnumerable<HtmlNode> nodes) + { + foreach (HtmlNode node in nodes) + { + yield return new WikiDataOverrideEntry + { + Ids = this.GetAttributeAsCsv(node, "data-id"), + ChangeLocalVersions = this.GetAttributeAsChangeDescriptor(node, "data-local-version", + 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 + ), + + ChangeUpdateKeys = this.GetAttributeAsChangeDescriptor(node, "data-update-keys", + raw => UpdateKey.TryParse(raw, out UpdateKey key) ? key.ToString() : raw + ) + }; + } + } + /// <summary>Get an attribute value.</summary> /// <param name="element">The element whose attributes to read.</param> /// <param name="name">The attribute name.</param> @@ -174,6 +219,18 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki return WebUtility.HtmlDecode(value); } + /// <summary>Get an attribute value and parse it as a change descriptor.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + /// <param name="formatValue">Format an raw entry value when applying changes.</param> + private ChangeDescriptor GetAttributeAsChangeDescriptor(HtmlNode element, string name, Func<string, string> formatValue) + { + string raw = this.GetAttribute(element, name); + return raw != null + ? ChangeDescriptor.Parse(raw, out _, formatValue) + : null; + } + /// <summary>Get an attribute value and parse it as a comma-delimited list of strings.</summary> /// <param name="element">The element whose attributes to read.</param> /// <param name="name">The attribute name.</param> @@ -221,28 +278,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki return null; } - /// <summary>Get an attribute value and parse it as a version mapping.</summary> - /// <param name="element">The element whose attributes to read.</param> - /// <param name="name">The attribute name.</param> - private IDictionary<string, string> GetAttributeAsVersionMapping(HtmlNode element, string name) - { - // get raw value - string raw = this.GetAttribute(element, name); - if (raw?.Contains("→") != true) - return null; - - // parse - // Specified on the wiki in the form "remote version → mapped version; another remote version → mapped version" - IDictionary<string, string> map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - foreach (string pair in raw.Split(';')) - { - string[] versions = pair.Split('→'); - if (versions.Length == 2 && !string.IsNullOrWhiteSpace(versions[0]) && !string.IsNullOrWhiteSpace(versions[1])) - map[versions[0].Trim()] = versions[1].Trim(); - } - return map; - } - /// <summary>Get the text of an element with the given class name.</summary> /// <param name="container">The metadata container.</param> /// <param name="className">The field name.</param> diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs new file mode 100644 index 00000000..e5ee81be --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs @@ -0,0 +1,31 @@ +#nullable enable + +using System; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>The data overrides to apply to matching mods.</summary> + public class WikiDataOverrideEntry + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod IDs for the mods to override.</summary> + public string[] Ids { get; set; } = Array.Empty<string>(); + + /// <summary>Maps local versions to a semantic version for update checks.</summary> + public ChangeDescriptor? ChangeLocalVersions { get; set; } + + /// <summary>Maps remote versions to a semantic version for update checks.</summary> + public ChangeDescriptor? ChangeRemoteVersions { get; set; } + + /// <summary>Update keys to add (optionally prefixed by '+'), remove (prefixed by '-'), or replace.</summary> + public ChangeDescriptor? ChangeUpdateKeys { get; set; } + + /// <summary>Whether the entry has any changes.</summary> + public bool HasChanges => + this.ChangeLocalVersions?.HasChanges == true + || this.ChangeRemoteVersions?.HasChanges == true + || this.ChangeUpdateKeys?.HasChanges == true; + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs index 21466c6a..4e0104da 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { /// <summary>A mod entry in the wiki list.</summary> @@ -63,14 +60,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> public string DevNote { get; set; } - /// <summary>Update keys to add (optionally prefixed by '+') or remove (prefixed by '-').</summary> - public string[] ChangeUpdateKeys { get; set; } - - /// <summary>Maps local versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapLocalVersions { get; set; } - - /// <summary>Maps remote versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapRemoteVersions { get; set; } + /// <summary>The data overrides to apply to the mod's manifest or remote mod page data, if any.</summary> + public WikiDataOverrideEntry Overrides { get; set; } /// <summary>The link anchor for the mod entry in the wiki compatibility list.</summary> public string Anchor { get; set; } diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index 7e4d0220..077c0361 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -89,6 +89,16 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData return new UpdateKey(raw, site, id, subkey); } + /// <summary>Parse a raw update key if it's valid.</summary> + /// <param name="raw">The raw update key to parse.</param> + /// <param name="parsed">The parsed update key, if valid.</param> + /// <returns>Returns whether the update key was successfully parsed.</returns> + public static bool TryParse(string raw, out UpdateKey parsed) + { + parsed = UpdateKey.Parse(raw); + return parsed.LooksValid; + } + /// <summary>Get a string that represents the current object.</summary> public override string ToString() { diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index c6e9a713..3ca07a08 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -144,7 +144,7 @@ namespace StardewModdingAPI.Web.Controllers } // fetch data - ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.MapRemoteVersions); + ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.Overrides?.ChangeRemoteVersions); if (data.Status != RemoteModStatus.Ok) { errors.Add(data.Error ?? data.Status.ToString()); @@ -195,7 +195,7 @@ namespace StardewModdingAPI.Web.Controllers } // get recommended update (if any) - ISemanticVersion installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions, allowNonStandard: allowNonStandardVersions); + ISemanticVersion installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.Overrides?.ChangeLocalVersions, allowNonStandard: allowNonStandardVersions); if (apiVersion != null && installedVersion != null) { // get newer versions @@ -255,8 +255,8 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Get the mod info for an update key.</summary> /// <param name="updateKey">The namespaced update key.</param> /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> - /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param> - private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions) + /// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param> + private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions) { // get mod page IModPage page; @@ -290,15 +290,12 @@ namespace StardewModdingAPI.Web.Controllers .Distinct() .ToList(); - // apply remove overrides from wiki + // apply overrides from wiki + if (entry.Overrides?.ChangeUpdateKeys?.HasChanges == true) { - var removeKeys = new HashSet<UpdateKey>( - from key in entry?.ChangeUpdateKeys ?? new string[0] - where key.StartsWith('-') - select UpdateKey.Parse(key.Substring(1)) - ); - if (removeKeys.Any()) - updateKeys.RemoveAll(removeKeys.Contains); + List<string> newKeys = updateKeys.Select(p => p.ToString()).ToList(); + entry.Overrides.ChangeUpdateKeys.Apply(newKeys); + updateKeys = newKeys.Select(UpdateKey.Parse).ToList(); } // 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 @@ -348,15 +345,6 @@ namespace StardewModdingAPI.Web.Controllers if (entry.ChucklefishID.HasValue) yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString()); } - - // overrides from wiki - foreach (string key in entry?.ChangeUpdateKeys ?? Array.Empty<string>()) - { - if (key.StartsWith('+')) - yield return key.Substring(1); - else if (!key.StartsWith("-")) - yield return key; - } } } } diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs index 8f21d2f5..a2b92aa4 100644 --- a/src/SMAPI.Web/Framework/ModSiteManager.cs +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients; @@ -55,9 +56,9 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Parse version info for the given mod page info.</summary> /// <param name="page">The mod page info.</param> /// <param name="subkey">The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.)</param> - /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param> + /// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param> /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> - public ModInfoModel GetPageVersions(IModPage page, string subkey, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions) + public ModInfoModel GetPageVersions(IModPage page, string subkey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions) { // get base model ModInfoModel model = new ModInfoModel() @@ -79,9 +80,9 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Get a semantic local version for update checks.</summary> /// <param name="version">The version to parse.</param> - /// <param name="map">A map of version replacements.</param> + /// <param name="map">Changes to apply to the raw version, if any.</param> /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> - public ISemanticVersion GetMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) + public ISemanticVersion GetMappedVersion(string version, ChangeDescriptor map, bool allowNonStandard) { // try mapped version string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard); @@ -102,10 +103,10 @@ namespace StardewModdingAPI.Web.Framework /// <param name="mod">The mod to check.</param> /// <param name="subkey">The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.)</param> /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> - /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param> + /// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param> /// <param name="main">The main mod version.</param> /// <param name="preview">The latest prerelease version, if newer than <paramref name="main"/>.</param> - private bool TryGetLatestVersions(IModPage mod, string subkey, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) + private bool TryGetLatestVersions(IModPage mod, string subkey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) { main = null; preview = null; @@ -179,31 +180,17 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Get a semantic local version for update checks.</summary> /// <param name="version">The version to map.</param> - /// <param name="map">A map of version replacements.</param> + /// <param name="map">Changes to apply to the raw version, if any.</param> /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> - private string GetRawMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) + private string GetRawMappedVersion(string version, ChangeDescriptor map, bool allowNonStandard) { - if (version == null || map == null || !map.Any()) + if (version == null || map?.HasChanges != true) return version; - // match exact raw version - if (map.ContainsKey(version)) - return map[version]; + var mapped = new List<string> { version }; + map.Apply(mapped); - // match parsed version - if (SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsed)) - { - if (map.ContainsKey(parsed.ToString())) - return map[parsed.ToString()]; - - foreach ((string fromRaw, string toRaw) in map) - { - if (SemanticVersion.TryParse(fromRaw, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(toRaw, allowNonStandard, out ISemanticVersion newVersion)) - return newVersion.ToString(); - } - } - - return version; + return mapped.FirstOrDefault(); } /// <summary>Normalize a version string.</summary> |