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/SMAPI.Toolkit/Framework | |
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/SMAPI.Toolkit/Framework')
6 files changed, 317 insertions, 64 deletions
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() { |