summaryrefslogtreecommitdiff
path: root/src/SMAPI.Toolkit/Framework
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2021-10-02 16:40:23 -0400
committerJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2021-10-02 16:40:23 -0400
commitb5c88d87d2cb1739585651e02513fef73dfc0e27 (patch)
treecd8a2afda5ee8cccc806574613f6de4073970c7a /src/SMAPI.Toolkit/Framework
parent0888f71a5c7fe2bbf815409a70834ac85013c7f8 (diff)
downloadSMAPI-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')
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs28
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs194
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs105
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs31
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs13
-rw-r--r--src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs10
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()
{