add support for unified mod data overrides on the wiki
This commit is contained in:
parent
0888f71a5c
commit
b5c88d87d2
|
@ -9,6 +9,7 @@
|
|||
* Fixed map tile rotations/flips not working for farmhands in split-screen mode.
|
||||
|
||||
* For the web UI:
|
||||
* Added support for unified [mod data overrides](https://stardewvalleywiki.com/Modding:Mod_compatibility#Mod_data_overrides) defined on the wiki.
|
||||
* The mod compatibility list now shows separate beta stats when 'show advanced info' is enabled.
|
||||
|
||||
## 3.12.7
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.");
|
||||
|
||||
// parse
|
||||
WikiModEntry[] mods = this.ParseEntries(modNodes).ToArray();
|
||||
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();
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
|
|
|
@ -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()
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue