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;
using StardewModdingAPI.Toolkit.Framework.UpdateData;
namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
{
/// An HTTP client for fetching mod metadata from the wiki.
public class WikiClient : IDisposable
{
/*********
** Fields
*********/
/// The underlying HTTP client.
private readonly IClient Client;
/*********
** Public methods
*********/
/// Construct an instance.
/// The user agent for the wiki API.
/// The base URL for the wiki API.
public WikiClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php")
{
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
}
/// Fetch mods from the compatibility list.
public async Task FetchModsAsync()
{
// fetch HTML
ResponseModel response = await this.Client
.GetAsync("")
.WithArguments(new
{
action = "parse",
page = "Modding:Mod_compatibility",
format = "json"
})
.As();
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;
// parse mod data overrides
Dictionary overrides = new Dictionary(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 (WikiDataOverrideEntry 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,
betaVersion: betaVersion,
mods: mods
);
}
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
public void Dispose()
{
this.Client.Dispose();
}
/*********
** Private methods
*********/
/// Parse valid mod compatibility entries.
/// The HTML compatibility entries.
/// The mod data overrides to apply, if any.
private IEnumerable ParseModEntries(IEnumerable nodes, IDictionary overridesById)
{
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? curseForgeID = this.GetAttributeAsNullableInt(node, "data-curseforge-id");
string? curseForgeKey = this.GetAttribute(node, "data-curseforge-key");
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");
string? devNote = this.GetAttribute(node, "data-dev-note");
string? pullRequestUrl = this.GetAttribute(node, "data-pr");
// parse stable compatibility
WikiCompatibilityInfo compatibility = new(
status: this.GetAttributeAsEnum(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(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")
);
}
}
// 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(
id: ids,
name: names,
author: authors,
nexusId: nexusID,
chucklefishId: chucklefishID,
curseForgeId: curseForgeID,
curseForgeKey: curseForgeKey,
modDropId: modDropID,
githubRepo: githubRepo,
customSourceUrl: customSourceUrl,
customUrl: customUrl,
contentPackFor: contentPackFor,
compatibility: compatibility,
betaCompatibility: betaCompatibility,
warnings: warnings,
pullRequestUrl: pullRequestUrl,
devNote: devNote,
overrides: overrides,
anchor: anchor
);
}
}
/// Parse valid mod data override entries.
/// The HTML mod data override entries.
private IEnumerable ParseOverrideEntries(IEnumerable 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
)
};
}
}
/// Get an attribute value.
/// The element whose attributes to read.
/// The attribute name.
private string? GetAttribute(HtmlNode element, string name)
{
string value = element.GetAttributeValue(name, null);
if (string.IsNullOrWhiteSpace(value))
return null;
return WebUtility.HtmlDecode(value);
}
/// Get an attribute value and parse it as a change descriptor.
/// The element whose attributes to read.
/// The attribute name.
/// Format an raw entry value when applying changes.
private ChangeDescriptor? GetAttributeAsChangeDescriptor(HtmlNode element, string name, Func formatValue)
{
string? raw = this.GetAttribute(element, name);
return raw != null
? ChangeDescriptor.Parse(raw, out _, formatValue)
: null;
}
/// Get an attribute value and parse it as a comma-delimited list of strings.
/// The element whose attributes to read.
/// The attribute name.
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()
: Array.Empty();
}
/// Get an attribute value and parse it as an enum value.
/// The enum type.
/// The element whose attributes to read.
/// The attribute name.
private TEnum? GetAttributeAsEnum(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;
}
/// Get an attribute value and parse it as a semantic version.
/// The element whose attributes to read.
/// The attribute name.
private ISemanticVersion? GetAttributeAsSemanticVersion(HtmlNode element, string name)
{
string? raw = this.GetAttribute(element, name);
return SemanticVersion.TryParse(raw, out ISemanticVersion? version)
? version
: null;
}
/// Get an attribute value and parse it as a nullable int.
/// The element whose attributes to read.
/// The attribute name.
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;
}
/// Get the text of an element with the given class name.
/// The metadata container.
/// The field name.
private string? GetInnerHtml(HtmlNode container, string className)
{
return container.Descendants().FirstOrDefault(p => p.HasClass(className))?.InnerHtml;
}
/// The response model for the MediaWiki parse API.
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")]
private class ResponseModel
{
/*********
** Accessors
*********/
/// The parse API results.
public ResponseParseModel Parse { get; }
/*********
** Public methods
*********/
/// Construct an instance.
/// The parse API results.
public ResponseModel(ResponseParseModel parse)
{
this.Parse = parse;
}
}
/// The inner response model for the MediaWiki parse API.
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
[SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")]
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")]
private class ResponseParseModel
{
/*********
** Accessors
*********/
/// The parsed text.
public IDictionary Text { get; } = new Dictionary();
}
}
}