summaryrefslogtreecommitdiff
path: root/src/SMAPI.Toolkit/Framework/Clients/Wiki
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2019-09-13 18:24:54 -0400
committerJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2019-09-13 18:24:54 -0400
commit6521df7b131924835eb797251c1e956fae0d6e13 (patch)
treeb704dc64b6b6fef72615bac8950d5eff3c80ea89 /src/SMAPI.Toolkit/Framework/Clients/Wiki
parente22a54212182d0adc443ac95bc791e83c90f7e10 (diff)
parentb7b8b001c5c2dc5d2c9fc1347532ca29368c2325 (diff)
downloadSMAPI-6521df7b131924835eb797251c1e956fae0d6e13.tar.gz
SMAPI-6521df7b131924835eb797251c1e956fae0d6e13.tar.bz2
SMAPI-6521df7b131924835eb797251c1e956fae0d6e13.zip
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI.Toolkit/Framework/Clients/Wiki')
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs237
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs24
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs27
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs54
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs18
5 files changed, 360 insertions, 0 deletions
diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs
new file mode 100644
index 00000000..3e9b8ea6
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs
@@ -0,0 +1,237 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Net;
+using System.Threading.Tasks;
+using HtmlAgilityPack;
+using Pathoschild.Http.Client;
+
+namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
+{
+ /// <summary>An HTTP client for fetching mod metadata from the wiki.</summary>
+ public class WikiClient : IDisposable
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The underlying HTTP client.</summary>
+ private readonly IClient Client;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="userAgent">The user agent for the wiki API.</param>
+ /// <param name="baseUrl">The base URL for the wiki API.</param>
+ public WikiClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php")
+ {
+ this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
+ }
+
+ /// <summary>Fetch mods from the compatibility list.</summary>
+ public async Task<WikiModList> FetchModsAsync()
+ {
+ // fetch HTML
+ ResponseModel response = await this.Client
+ .GetAsync("")
+ .WithArguments(new
+ {
+ action = "parse",
+ page = "Modding:Mod_compatibility",
+ format = "json"
+ })
+ .As<ResponseModel>();
+ string html = response.Parse.Text["*"];
+
+ // parse HTML
+ var doc = new HtmlDocument();
+ doc.LoadHtml(html);
+
+ // fetch game versions
+ string stableVersion = doc.DocumentNode.SelectSingleNode("div[@class='game-stable-version']")?.InnerText;
+ string betaVersion = doc.DocumentNode.SelectSingleNode("div[@class='game-beta-version']")?.InnerText;
+ 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
+ WikiModEntry[] mods = this.ParseEntries(modNodes).ToArray();
+ return new WikiModList
+ {
+ StableVersion = stableVersion,
+ BetaVersion = betaVersion,
+ Mods = mods
+ };
+ }
+
+ /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+ public void Dispose()
+ {
+ this.Client?.Dispose();
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Parse valid mod compatibility entries.</summary>
+ /// <param name="nodes">The HTML compatibility entries.</param>
+ private IEnumerable<WikiModEntry> ParseEntries(IEnumerable<HtmlNode> nodes)
+ {
+ foreach (HtmlNode node in nodes)
+ {
+ // extract fields
+ string[] names = this.GetAttributeAsCsv(node, "data-name");
+ string[] authors = this.GetAttributeAsCsv(node, "data-author");
+ string[] ids = this.GetAttributeAsCsv(node, "data-id");
+ string[] warnings = this.GetAttributeAsCsv(node, "data-warnings");
+ int? nexusID = this.GetAttributeAsNullableInt(node, "data-nexus-id");
+ int? chucklefishID = this.GetAttributeAsNullableInt(node, "data-cf-id");
+ int? modDropID = this.GetAttributeAsNullableInt(node, "data-moddrop-id");
+ string githubRepo = this.GetAttribute(node, "data-github");
+ string customSourceUrl = this.GetAttribute(node, "data-custom-source");
+ string customUrl = this.GetAttribute(node, "data-url");
+ string anchor = this.GetAttribute(node, "id");
+ string contentPackFor = this.GetAttribute(node, "data-content-pack-for");
+
+ // parse stable compatibility
+ WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo
+ {
+ Status = this.GetAttributeAsEnum<WikiCompatibilityStatus>(node, "data-status") ?? WikiCompatibilityStatus.Ok,
+ BrokeIn = this.GetAttribute(node, "data-broke-in"),
+ UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-unofficial-version"),
+ UnofficialUrl = this.GetAttribute(node, "data-unofficial-url"),
+ Summary = this.GetInnerHtml(node, "mod-summary")?.Trim()
+ };
+
+ // parse beta compatibility
+ WikiCompatibilityInfo betaCompatibility = null;
+ {
+ WikiCompatibilityStatus? betaStatus = this.GetAttributeAsEnum<WikiCompatibilityStatus>(node, "data-beta-status");
+ if (betaStatus.HasValue)
+ {
+ betaCompatibility = new WikiCompatibilityInfo
+ {
+ Status = betaStatus.Value,
+ BrokeIn = this.GetAttribute(node, "data-beta-broke-in"),
+ UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-beta-unofficial-version"),
+ UnofficialUrl = this.GetAttribute(node, "data-beta-unofficial-url"),
+ Summary = this.GetInnerHtml(node, "mod-beta-summary")
+ };
+ }
+ }
+
+ // yield model
+ yield return new WikiModEntry
+ {
+ ID = ids,
+ Name = names,
+ Author = authors,
+ NexusID = nexusID,
+ ChucklefishID = chucklefishID,
+ ModDropID = modDropID,
+ GitHubRepo = githubRepo,
+ CustomSourceUrl = customSourceUrl,
+ CustomUrl = customUrl,
+ ContentPackFor = contentPackFor,
+ Compatibility = compatibility,
+ BetaCompatibility = betaCompatibility,
+ Warnings = warnings,
+ Anchor = anchor
+ };
+ }
+ }
+
+ /// <summary>Get an attribute value.</summary>
+ /// <param name="element">The element whose attributes to read.</param>
+ /// <param name="name">The attribute name.</param>
+ private string GetAttribute(HtmlNode element, string name)
+ {
+ string value = element.GetAttributeValue(name, null);
+ if (string.IsNullOrWhiteSpace(value))
+ return null;
+
+ return WebUtility.HtmlDecode(value);
+ }
+
+ /// <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>
+ private string[] GetAttributeAsCsv(HtmlNode element, string name)
+ {
+ string raw = this.GetAttribute(element, name);
+ return !string.IsNullOrWhiteSpace(raw)
+ ? raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray()
+ : new string[0];
+ }
+
+ /// <summary>Get an attribute value and parse it as an enum value.</summary>
+ /// <typeparam name="TEnum">The enum type.</typeparam>
+ /// <param name="element">The element whose attributes to read.</param>
+ /// <param name="name">The attribute name.</param>
+ private TEnum? GetAttributeAsEnum<TEnum>(HtmlNode element, string name) where TEnum : struct
+ {
+ string raw = this.GetAttribute(element, name);
+ if (raw == null)
+ return null;
+ if (!Enum.TryParse(raw, true, out TEnum value) && Enum.IsDefined(typeof(TEnum), value))
+ throw new InvalidOperationException($"Unknown {typeof(TEnum).Name} value '{raw}' when parsing compatibility list.");
+ return value;
+ }
+
+ /// <summary>Get an attribute value and parse it as a semantic version.</summary>
+ /// <param name="element">The element whose attributes to read.</param>
+ /// <param name="name">The attribute name.</param>
+ private ISemanticVersion GetAttributeAsSemanticVersion(HtmlNode element, string name)
+ {
+ string raw = this.GetAttribute(element, name);
+ return SemanticVersion.TryParse(raw, out ISemanticVersion version)
+ ? version
+ : null;
+ }
+
+ /// <summary>Get an attribute value and parse it as a nullable int.</summary>
+ /// <param name="element">The element whose attributes to read.</param>
+ /// <param name="name">The attribute name.</param>
+ private int? GetAttributeAsNullableInt(HtmlNode element, string name)
+ {
+ string raw = this.GetAttribute(element, name);
+ if (raw != null && int.TryParse(raw, out int value))
+ return value;
+ return null;
+ }
+
+ /// <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>
+ private string GetInnerHtml(HtmlNode container, string className)
+ {
+ return container.Descendants().FirstOrDefault(p => p.HasClass(className))?.InnerHtml;
+ }
+
+ /// <summary>The response model for the MediaWiki parse API.</summary>
+ [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
+ [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")]
+ private class ResponseModel
+ {
+ /// <summary>The parse API results.</summary>
+ public ResponseParseModel Parse { get; set; }
+ }
+
+ /// <summary>The inner response model for the MediaWiki parse API.</summary>
+ [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
+ [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")]
+ [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")]
+ private class ResponseParseModel
+ {
+ /// <summary>The parsed text.</summary>
+ public IDictionary<string, string> Text { get; set; }
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs
new file mode 100644
index 00000000..204acd2b
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs
@@ -0,0 +1,24 @@
+namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
+{
+ /// <summary>Compatibility info for a mod.</summary>
+ public class WikiCompatibilityInfo
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The compatibility status.</summary>
+ public WikiCompatibilityStatus Status { get; set; }
+
+ /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary>
+ public string Summary { get; set; }
+
+ /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary>
+ public string BrokeIn { get; set; }
+
+ /// <summary>The version of the latest unofficial update, if applicable.</summary>
+ public ISemanticVersion UnofficialVersion { get; set; }
+
+ /// <summary>The URL to the latest unofficial update, if applicable.</summary>
+ public string UnofficialUrl { get; set; }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs
new file mode 100644
index 00000000..a1d2dfae
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs
@@ -0,0 +1,27 @@
+namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
+{
+ /// <summary>The compatibility status for a mod.</summary>
+ public enum WikiCompatibilityStatus
+ {
+ /// <summary>The mod is compatible.</summary>
+ Ok = 0,
+
+ /// <summary>The mod is compatible if you use an optional official download.</summary>
+ Optional = 1,
+
+ /// <summary>The mod is compatible if you use an unofficial update.</summary>
+ Unofficial = 2,
+
+ /// <summary>The mod isn't compatible, but the player can fix it or there's a good alternative.</summary>
+ Workaround = 3,
+
+ /// <summary>The mod isn't compatible.</summary>
+ Broken = 4,
+
+ /// <summary>The mod is no longer maintained by the author, and an unofficial update or continuation is unlikely.</summary>
+ Abandoned = 5,
+
+ /// <summary>The mod is no longer needed and should be removed.</summary>
+ Obsolete = 6
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs
new file mode 100644
index 00000000..cf416cc6
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs
@@ -0,0 +1,54 @@
+namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
+{
+ /// <summary>A mod entry in the wiki list.</summary>
+ public class WikiModEntry
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to newest order.</summary>
+ public string[] ID { get; set; }
+
+ /// <summary>The mod's display name. If the mod has multiple names, the first one is the most canonical name.</summary>
+ public string[] Name { get; set; }
+
+ /// <summary>The mod's author name. If the author has multiple names, the first one is the most canonical name.</summary>
+ public string[] Author { get; set; }
+
+ /// <summary>The mod ID on Nexus.</summary>
+ public int? NexusID { get; set; }
+
+ /// <summary>The mod ID in the Chucklefish mod repo.</summary>
+ public int? ChucklefishID { get; set; }
+
+ /// <summary>The mod ID in the ModDrop mod repo.</summary>
+ public int? ModDropID { get; set; }
+
+ /// <summary>The GitHub repository in the form 'owner/repo'.</summary>
+ public string GitHubRepo { get; set; }
+
+ /// <summary>The URL to a non-GitHub source repo.</summary>
+ public string CustomSourceUrl { get; set; }
+
+ /// <summary>The custom mod page URL (if applicable).</summary>
+ public string CustomUrl { get; set; }
+
+ /// <summary>The name of the mod which loads this content pack, if applicable.</summary>
+ public string ContentPackFor { get; set; }
+
+ /// <summary>The mod's compatibility with the latest stable version of the game.</summary>
+ public WikiCompatibilityInfo Compatibility { get; set; }
+
+ /// <summary>The mod's compatibility with the latest beta version of the game (if any).</summary>
+ public WikiCompatibilityInfo BetaCompatibility { get; set; }
+
+ /// <summary>Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, <see cref="BetaCompatibility"/> should be used for beta versions of SMAPI instead of <see cref="Compatibility"/>.</summary>
+ public bool HasBetaInfo => this.BetaCompatibility != null;
+
+ /// <summary>The human-readable warnings for players about this mod.</summary>
+ public string[] Warnings { 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/Clients/Wiki/WikiModList.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs
new file mode 100644
index 00000000..0d614f28
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs
@@ -0,0 +1,18 @@
+namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
+{
+ /// <summary>Metadata from the wiki's mod compatibility list.</summary>
+ public class WikiModList
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The stable game version.</summary>
+ public string StableVersion { get; set; }
+
+ /// <summary>The beta game version (if any).</summary>
+ public string BetaVersion { get; set; }
+
+ /// <summary>The mods on the wiki.</summary>
+ public WikiModEntry[] Mods { get; set; }
+ }
+}