summaryrefslogtreecommitdiff
path: root/src/SMAPI.Toolkit/Framework
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI.Toolkit/Framework')
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs33
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs31
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs123
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs36
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs34
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs73
-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
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs14
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs82
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs18
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs127
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs147
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs54
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs65
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/ModStatus.cs18
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs36
-rw-r--r--src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs64
-rw-r--r--src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs183
-rw-r--r--src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs21
-rw-r--r--src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs73
24 files changed, 1592 insertions, 0 deletions
diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
new file mode 100644
index 00000000..8a9c0a25
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
@@ -0,0 +1,33 @@
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
+{
+ /// <summary>Metadata about a mod.</summary>
+ public class ModEntryModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's unique ID (if known).</summary>
+ public string ID { get; set; }
+
+ /// <summary>The main version.</summary>
+ public ModEntryVersionModel Main { get; set; }
+
+ /// <summary>The latest optional version, if newer than <see cref="Main"/>.</summary>
+ public ModEntryVersionModel Optional { get; set; }
+
+ /// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary>
+ public ModEntryVersionModel Unofficial { get; set; }
+
+ /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see <see cref="HasBetaInfo"/>).</summary>
+ public ModEntryVersionModel UnofficialForBeta { get; set; }
+
+ /// <summary>Optional extended data which isn't needed for update checks.</summary>
+ public ModExtendedMetadataModel Metadata { get; set; }
+
+ /// <summary>Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, <see cref="UnofficialForBeta"/> should be used for beta versions of SMAPI instead of <see cref="Unofficial"/>.</summary>
+ public bool HasBetaInfo { get; set; }
+
+ /// <summary>The errors that occurred while fetching update data.</summary>
+ public string[] Errors { get; set; } = new string[0];
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs
new file mode 100644
index 00000000..dadb8c10
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs
@@ -0,0 +1,31 @@
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
+{
+ /// <summary>Metadata about a version.</summary>
+ public class ModEntryVersionModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The version number.</summary>
+ public ISemanticVersion Version { get; set; }
+
+ /// <summary>The mod page URL.</summary>
+ public string Url { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public ModEntryVersionModel() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="version">The version number.</param>
+ /// <param name="url">The mod page URL.</param>
+ public ModEntryVersionModel(ISemanticVersion version, string url)
+ {
+ this.Version = version;
+ this.Url = url;
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
new file mode 100644
index 00000000..989c18b0
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
@@ -0,0 +1,123 @@
+using System.Collections.Generic;
+using System.Linq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
+using StardewModdingAPI.Toolkit.Framework.ModData;
+
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
+{
+ /// <summary>Extended metadata about a mod.</summary>
+ public class ModExtendedMetadataModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /****
+ ** Mod info
+ ****/
+ /// <summary>The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates).</summary>
+ public string[] ID { get; set; } = new string[0];
+
+ /// <summary>The mod's display name.</summary>
+ public string Name { 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; }
+
+
+ /****
+ ** Stable compatibility
+ ****/
+ /// <summary>The compatibility status.</summary>
+ [JsonConverter(typeof(StringEnumConverter))]
+ public WikiCompatibilityStatus? CompatibilityStatus { get; set; }
+
+ /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatitng.</summary>
+ public string CompatibilitySummary { get; set; }
+
+ /// <summary>The game or SMAPI version which broke this mod, if applicable.</summary>
+ public string BrokeIn { get; set; }
+
+
+ /****
+ ** Beta compatibility
+ ****/
+ /// <summary>The compatibility status for the Stardew Valley beta (if any).</summary>
+ [JsonConverter(typeof(StringEnumConverter))]
+ public WikiCompatibilityStatus? BetaCompatibilityStatus { get; set; }
+
+ /// <summary>The human-readable summary of the compatibility status or workaround for the Stardew Valley beta (if any), without HTML formatitng.</summary>
+ public string BetaCompatibilitySummary { get; set; }
+
+ /// <summary>The beta game or SMAPI version which broke this mod, if applicable.</summary>
+ public string BetaBrokeIn { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public ModExtendedMetadataModel() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="wiki">The mod metadata from the wiki (if available).</param>
+ /// <param name="db">The mod metadata from SMAPI's internal DB (if available).</param>
+ public ModExtendedMetadataModel(WikiModEntry wiki, ModDataRecord db)
+ {
+ // wiki data
+ if (wiki != null)
+ {
+ this.ID = wiki.ID;
+ this.Name = wiki.Name.FirstOrDefault();
+ this.NexusID = wiki.NexusID;
+ this.ChucklefishID = wiki.ChucklefishID;
+ this.ModDropID = wiki.ModDropID;
+ this.GitHubRepo = wiki.GitHubRepo;
+ this.CustomSourceUrl = wiki.CustomSourceUrl;
+ this.CustomUrl = wiki.CustomUrl;
+
+ this.CompatibilityStatus = wiki.Compatibility.Status;
+ this.CompatibilitySummary = wiki.Compatibility.Summary;
+ this.BrokeIn = wiki.Compatibility.BrokeIn;
+
+ this.BetaCompatibilityStatus = wiki.BetaCompatibility?.Status;
+ this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary;
+ this.BetaBrokeIn = wiki.BetaCompatibility?.BrokeIn;
+ }
+
+ // internal DB data
+ if (db != null)
+ {
+ this.ID = this.ID.Union(db.FormerIDs).ToArray();
+ this.Name = 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/WebApi/ModSeachModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs
new file mode 100644
index 00000000..e352e1cc
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs
@@ -0,0 +1,36 @@
+using System.Linq;
+
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
+{
+ /// <summary>Specifies mods whose update-check info to fetch.</summary>
+ public class ModSearchModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mods for which to find data.</summary>
+ public ModSearchEntryModel[] Mods { get; set; }
+
+ /// <summary>Whether to include extended metadata for each mod.</summary>
+ public bool IncludeExtendedMetadata { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an empty instance.</summary>
+ public ModSearchModel()
+ {
+ // needed for JSON deserialising
+ }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mods">The mods to search.</param>
+ /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param>
+ public ModSearchModel(ModSearchEntryModel[] mods, bool includeExtendedMetadata)
+ {
+ this.Mods = mods.ToArray();
+ this.IncludeExtendedMetadata = includeExtendedMetadata;
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs
new file mode 100644
index 00000000..bca47647
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs
@@ -0,0 +1,34 @@
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
+{
+ /// <summary>Specifies the identifiers for a mod to match.</summary>
+ public class ModSearchEntryModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The unique mod ID.</summary>
+ public string ID { get; set; }
+
+ /// <summary>The namespaced mod update keys (if available).</summary>
+ public string[] UpdateKeys { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an empty instance.</summary>
+ public ModSearchEntryModel()
+ {
+ // needed for JSON deserialising
+ }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="id">The unique mod ID.</param>
+ /// <param name="updateKeys">The namespaced mod update keys (if available).</param>
+ public ModSearchEntryModel(string id, string[] updateKeys)
+ {
+ this.ID = id;
+ this.UpdateKeys = updateKeys ?? new string[0];
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs
new file mode 100644
index 00000000..7c3df384
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using Newtonsoft.Json;
+using StardewModdingAPI.Toolkit.Serialisation;
+
+namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
+{
+ /// <summary>Provides methods for interacting with the SMAPI web API.</summary>
+ public class WebApiClient
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The base URL for the web API.</summary>
+ private readonly Uri BaseUrl;
+
+ /// <summary>The API version number.</summary>
+ private readonly ISemanticVersion Version;
+
+ /// <summary>The JSON serializer settings to use.</summary>
+ private readonly JsonSerializerSettings JsonSettings = new JsonHelper().JsonSettings;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="baseUrl">The base URL for the web API.</param>
+ /// <param name="version">The web API version.</param>
+ public WebApiClient(string baseUrl, ISemanticVersion version)
+ {
+ this.BaseUrl = new Uri(baseUrl);
+ this.Version = version;
+ }
+
+ /// <summary>Get metadata about a set of mods from the web API.</summary>
+ /// <param name="mods">The mod keys for which to fetch the latest version.</param>
+ /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param>
+ public IDictionary<string, ModEntryModel> GetModInfo(ModSearchEntryModel[] mods, bool includeExtendedMetadata = false)
+ {
+ return this.Post<ModSearchModel, ModEntryModel[]>(
+ $"v{this.Version}/mods",
+ new ModSearchModel(mods, includeExtendedMetadata)
+ ).ToDictionary(p => p.ID);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Fetch the response from the backend API.</summary>
+ /// <typeparam name="TBody">The body content type.</typeparam>
+ /// <typeparam name="TResult">The expected response type.</typeparam>
+ /// <param name="url">The request URL, optionally excluding the base URL.</param>
+ /// <param name="content">The body content to post.</param>
+ private TResult Post<TBody, TResult>(string url, TBody content)
+ {
+ // note: avoid HttpClient for Mac compatibility
+ using (WebClient client = new WebClient())
+ {
+ Uri fullUrl = new Uri(this.BaseUrl, url);
+ string data = JsonConvert.SerializeObject(content);
+
+ client.Headers["Content-Type"] = "application/json";
+ client.Headers["User-Agent"] = $"SMAPI/{this.Version}";
+ string response = client.UploadString(fullUrl, data);
+ return JsonConvert.DeserializeObject<TResult>(response, this.JsonSettings);
+ }
+ }
+ }
+}
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; }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs b/src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs
new file mode 100644
index 00000000..ef6d4dd9
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs
@@ -0,0 +1,14 @@
+using System.Collections.Generic;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>The SMAPI predefined metadata.</summary>
+ internal class MetadataModel
+ {
+ /********
+ ** Accessors
+ ********/
+ /// <summary>Extra metadata about mods.</summary>
+ public IDictionary<string, ModDataModel> ModData { get; set; }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs
new file mode 100644
index 00000000..b3954693
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs
@@ -0,0 +1,82 @@
+using System.Linq;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>A versioned mod metadata field.</summary>
+ public class ModDataField
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The field key.</summary>
+ public ModDataFieldKey Key { get; }
+
+ /// <summary>The field value.</summary>
+ public string Value { get; }
+
+ /// <summary>Whether this field should only be applied if it's not already set.</summary>
+ public bool IsDefault { get; }
+
+ /// <summary>The lowest version in the range, or <c>null</c> for all past versions.</summary>
+ public ISemanticVersion LowerVersion { get; }
+
+ /// <summary>The highest version in the range, or <c>null</c> for all future versions.</summary>
+ public ISemanticVersion UpperVersion { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="key">The field key.</param>
+ /// <param name="value">The field value.</param>
+ /// <param name="isDefault">Whether this field should only be applied if it's not already set.</param>
+ /// <param name="lowerVersion">The lowest version in the range, or <c>null</c> for all past versions.</param>
+ /// <param name="upperVersion">The highest version in the range, or <c>null</c> for all future versions.</param>
+ public ModDataField(ModDataFieldKey key, string value, bool isDefault, ISemanticVersion lowerVersion, ISemanticVersion upperVersion)
+ {
+ this.Key = key;
+ this.Value = value;
+ this.IsDefault = isDefault;
+ this.LowerVersion = lowerVersion;
+ this.UpperVersion = upperVersion;
+ }
+
+ /// <summary>Get whether this data field applies for the given manifest.</summary>
+ /// <param name="manifest">The mod manifest.</param>
+ public bool IsMatch(IManifest manifest)
+ {
+ return
+ manifest?.Version != null // ignore invalid manifest
+ && (!this.IsDefault || !this.HasFieldValue(manifest, this.Key))
+ && (this.LowerVersion == null || !manifest.Version.IsOlderThan(this.LowerVersion))
+ && (this.UpperVersion == null || !manifest.Version.IsNewerThan(this.UpperVersion));
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get whether a manifest field has a meaningful value for the purposes of enforcing <see cref="IsDefault"/>.</summary>
+ /// <param name="manifest">The mod manifest.</param>
+ /// <param name="key">The field key matching <see cref="ModDataFieldKey"/>.</param>
+ private bool HasFieldValue(IManifest manifest, ModDataFieldKey key)
+ {
+ switch (key)
+ {
+ // update key
+ case ModDataFieldKey.UpdateKey:
+ return manifest.UpdateKeys != null && manifest.UpdateKeys.Any(p => !string.IsNullOrWhiteSpace(p));
+
+ // non-manifest fields
+ case ModDataFieldKey.AlternativeUrl:
+ case ModDataFieldKey.StatusReasonPhrase:
+ case ModDataFieldKey.Status:
+ return false;
+
+ default:
+ return false;
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs
new file mode 100644
index 00000000..09dd0cc5
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs
@@ -0,0 +1,18 @@
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>The valid field keys.</summary>
+ public enum ModDataFieldKey
+ {
+ /// <summary>A manifest update key.</summary>
+ UpdateKey,
+
+ /// <summary>An alternative URL the player can check for an updated version.</summary>
+ AlternativeUrl,
+
+ /// <summary>The mod's predefined compatibility status.</summary>
+ Status,
+
+ /// <summary>A reason phrase for the <see cref="Status"/>, or <c>null</c> to use the default reason.</summary>
+ StatusReasonPhrase
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs
new file mode 100644
index 00000000..18039762
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.Serialization;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>The raw mod metadata from SMAPI's internal mod list.</summary>
+ internal class ModDataModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's current unique ID.</summary>
+ public string ID { get; set; }
+
+ /// <summary>The former mod IDs (if any).</summary>
+ /// <remarks>
+ /// This uses a custom format which uniquely identifies a mod across multiple versions and
+ /// supports matching other fields if no ID was specified. This doesn't include the latest
+ /// ID, if any. If the mod's ID changed over time, multiple variants can be separated by the
+ /// <c>|</c> character.
+ /// </remarks>
+ public string FormerIDs { get; set; }
+
+ /// <summary>Maps local versions to a semantic version for update checks.</summary>
+ public IDictionary<string, string> MapLocalVersions { get; set; } = new Dictionary<string, string>();
+
+ /// <summary>Maps remote versions to a semantic version for update checks.</summary>
+ public IDictionary<string, string> MapRemoteVersions { get; set; } = new Dictionary<string, string>();
+
+ /// <summary>The mod warnings to suppress, even if they'd normally be shown.</summary>
+ public ModWarning SuppressWarnings { get; set; }
+
+ /// <summary>This field stores properties that aren't mapped to another field before they're parsed into <see cref="Fields"/>.</summary>
+ [JsonExtensionData]
+ public IDictionary<string, JToken> ExtensionData { get; set; }
+
+ /// <summary>The versioned field data.</summary>
+ /// <remarks>
+ /// This maps field names to values. This should be accessed via <see cref="GetFields"/>.
+ /// Format notes:
+ /// - Each key consists of a field name prefixed with any combination of version range
+ /// and <c>Default</c>, separated by pipes (whitespace trimmed). For example, <c>Name</c>
+ /// will always override the name, <c>Default | Name</c> will only override a blank
+ /// name, and <c>~1.1 | Default | Name</c> will override blank names up to version 1.1.
+ /// - The version format is <c>min~max</c> (where either side can be blank for unbounded), or
+ /// a single version number.
+ /// - The field name itself corresponds to a <see cref="ModDataFieldKey"/> value.
+ /// </remarks>
+ public IDictionary<string, string> Fields { get; set; } = new Dictionary<string, string>();
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get a parsed representation of the <see cref="Fields"/>.</summary>
+ public IEnumerable<ModDataField> GetFields()
+ {
+ foreach (KeyValuePair<string, string> pair in this.Fields)
+ {
+ // init fields
+ string packedKey = pair.Key;
+ string value = pair.Value;
+ bool isDefault = false;
+ ISemanticVersion lowerVersion = null;
+ ISemanticVersion upperVersion = null;
+
+ // parse
+ string[] parts = packedKey.Split('|').Select(p => p.Trim()).ToArray();
+ ModDataFieldKey fieldKey = (ModDataFieldKey)Enum.Parse(typeof(ModDataFieldKey), parts.Last(), ignoreCase: true);
+ foreach (string part in parts.Take(parts.Length - 1))
+ {
+ // 'default'
+ if (part.Equals("Default", StringComparison.InvariantCultureIgnoreCase))
+ {
+ isDefault = true;
+ continue;
+ }
+
+ // version range
+ if (part.Contains("~"))
+ {
+ string[] versionParts = part.Split(new[] { '~' }, 2);
+ lowerVersion = versionParts[0] != "" ? new SemanticVersion(versionParts[0]) : null;
+ upperVersion = versionParts[1] != "" ? new SemanticVersion(versionParts[1]) : null;
+ continue;
+ }
+
+ // single version
+ lowerVersion = new SemanticVersion(part);
+ upperVersion = new SemanticVersion(part);
+ }
+
+ yield return new ModDataField(fieldKey, value, isDefault, lowerVersion, upperVersion);
+ }
+ }
+
+ /// <summary>Get the former mod IDs.</summary>
+ public IEnumerable<string> GetFormerIDs()
+ {
+ if (this.FormerIDs != null)
+ {
+ foreach (string id in this.FormerIDs.Split('|'))
+ yield return id.Trim();
+ }
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>The method invoked after JSON deserialisation.</summary>
+ /// <param name="context">The deserialisation context.</param>
+ [OnDeserialized]
+ private void OnDeserialized(StreamingContext context)
+ {
+ if (this.ExtensionData != null)
+ {
+ this.Fields = this.ExtensionData.ToDictionary(p => p.Key, p => p.Value.ToString());
+ this.ExtensionData = null;
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs
new file mode 100644
index 00000000..794ad2e4
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs
@@ -0,0 +1,147 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>The parsed mod metadata from SMAPI's internal mod list.</summary>
+ public class ModDataRecord
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's default display name.</summary>
+ public string DisplayName { get; }
+
+ /// <summary>The mod's current unique ID.</summary>
+ public string ID { get; }
+
+ /// <summary>The former mod IDs (if any).</summary>
+ public string[] FormerIDs { get; }
+
+ /// <summary>The mod warnings to suppress, even if they'd normally be shown.</summary>
+ public ModWarning SuppressWarnings { get; set; }
+
+ /// <summary>Maps local versions to a semantic version for update checks.</summary>
+ public IDictionary<string, string> MapLocalVersions { get; }
+
+ /// <summary>Maps remote versions to a semantic version for update checks.</summary>
+ public IDictionary<string, string> MapRemoteVersions { get; }
+
+ /// <summary>The versioned field data.</summary>
+ public ModDataField[] Fields { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="displayName">The mod's default display name.</param>
+ /// <param name="model">The raw data model.</param>
+ internal ModDataRecord(string displayName, ModDataModel model)
+ {
+ this.DisplayName = displayName;
+ this.ID = model.ID;
+ this.FormerIDs = model.GetFormerIDs().ToArray();
+ this.SuppressWarnings = model.SuppressWarnings;
+ this.MapLocalVersions = new Dictionary<string, string>(model.MapLocalVersions, StringComparer.InvariantCultureIgnoreCase);
+ this.MapRemoteVersions = new Dictionary<string, string>(model.MapRemoteVersions, StringComparer.InvariantCultureIgnoreCase);
+ this.Fields = model.GetFields().ToArray();
+ }
+
+ /// <summary>Get whether the mod has (or previously had) the given ID.</summary>
+ /// <param name="id">The mod ID.</param>
+ public bool HasID(string id)
+ {
+ // try main ID
+ if (this.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase))
+ return true;
+
+ // try former IDs
+ foreach (string formerID in this.FormerIDs)
+ {
+ if (formerID.Equals(id, StringComparison.InvariantCultureIgnoreCase))
+ return true;
+ }
+
+ return false;
+ }
+
+ /// <summary>Get a semantic local version for update checks.</summary>
+ /// <param name="version">The remote version to normalise.</param>
+ public ISemanticVersion GetLocalVersionForUpdateChecks(ISemanticVersion version)
+ {
+ return this.MapLocalVersions != null && this.MapLocalVersions.TryGetValue(version.ToString(), out string newVersion)
+ ? new SemanticVersion(newVersion)
+ : version;
+ }
+
+ /// <summary>Get a semantic remote version for update checks.</summary>
+ /// <param name="version">The remote version to normalise.</param>
+ public string GetRemoteVersionForUpdateChecks(string version)
+ {
+ // normalise version if possible
+ if (SemanticVersion.TryParse(version, out ISemanticVersion parsed))
+ version = parsed.ToString();
+
+ // fetch remote version
+ return this.MapRemoteVersions != null && this.MapRemoteVersions.TryGetValue(version, out string newVersion)
+ ? newVersion
+ : version;
+ }
+
+ /// <summary>Get the possible mod IDs.</summary>
+ public IEnumerable<string> GetIDs()
+ {
+ return this.FormerIDs
+ .Concat(new[] { this.ID })
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Select(p => p.Trim())
+ .Distinct();
+ }
+
+ /// <summary>Get the default update key for this mod, if any.</summary>
+ public string GetDefaultUpdateKey()
+ {
+ string updateKey = this.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value;
+ return !string.IsNullOrWhiteSpace(updateKey)
+ ? updateKey
+ : null;
+ }
+
+ /// <summary>Get a parsed representation of the <see cref="ModDataRecord.Fields"/> which match a given manifest.</summary>
+ /// <param name="manifest">The manifest to match.</param>
+ public ModDataRecordVersionedFields GetVersionedFields(IManifest manifest)
+ {
+ ModDataRecordVersionedFields parsed = new ModDataRecordVersionedFields { DisplayName = this.DisplayName, DataRecord = this };
+ foreach (ModDataField field in this.Fields.Where(field => field.IsMatch(manifest)))
+ {
+ switch (field.Key)
+ {
+ // update key
+ case ModDataFieldKey.UpdateKey:
+ parsed.UpdateKey = field.Value;
+ break;
+
+ // alternative URL
+ case ModDataFieldKey.AlternativeUrl:
+ parsed.AlternativeUrl = field.Value;
+ break;
+
+ // status
+ case ModDataFieldKey.Status:
+ parsed.Status = (ModStatus)Enum.Parse(typeof(ModStatus), field.Value, ignoreCase: true);
+ parsed.StatusUpperVersion = field.UpperVersion;
+ break;
+
+ // status reason phrase
+ case ModDataFieldKey.StatusReasonPhrase:
+ parsed.StatusReasonPhrase = field.Value;
+ break;
+ }
+ }
+
+ return parsed;
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs
new file mode 100644
index 00000000..237f2c66
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs
@@ -0,0 +1,54 @@
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>The versioned fields from a <see cref="ModDataRecord"/> for a specific manifest.</summary>
+ public class ModDataRecordVersionedFields
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The underlying data record.</summary>
+ public ModDataRecord DataRecord { get; set; }
+
+ /// <summary>The default mod name to display when the name isn't available (e.g. during dependency checks).</summary>
+ public string DisplayName { get; set; }
+
+ /// <summary>The update key to apply.</summary>
+ public string UpdateKey { get; set; }
+
+ /// <summary>The alternative URL the player can check for an updated version.</summary>
+ public string AlternativeUrl { get; set; }
+
+ /// <summary>The predefined compatibility status.</summary>
+ public ModStatus Status { get; set; } = ModStatus.None;
+
+ /// <summary>A reason phrase for the <see cref="Status"/>, or <c>null</c> to use the default reason.</summary>
+ public string StatusReasonPhrase { get; set; }
+
+ /// <summary>The upper version for which the <see cref="Status"/> applies (if any).</summary>
+ public ISemanticVersion StatusUpperVersion { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Get a semantic local version for update checks.</summary>
+ /// <param name="version">The remote version to normalise.</param>
+ public ISemanticVersion GetLocalVersionForUpdateChecks(ISemanticVersion version)
+ {
+ return this.DataRecord.GetLocalVersionForUpdateChecks(version);
+ }
+
+ /// <summary>Get a semantic remote version for update checks.</summary>
+ /// <param name="version">The remote version to normalise.</param>
+ public ISemanticVersion GetRemoteVersionForUpdateChecks(ISemanticVersion version)
+ {
+ if (version == null)
+ return null;
+
+ string rawVersion = this.DataRecord.GetRemoteVersionForUpdateChecks(version.ToString());
+ return rawVersion != null
+ ? new SemanticVersion(rawVersion)
+ : version;
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs
new file mode 100644
index 00000000..a9da884a
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>Handles access to SMAPI's internal mod metadata list.</summary>
+ public class ModDatabase
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The underlying mod data records indexed by default display name.</summary>
+ private readonly ModDataRecord[] Records;
+
+ /// <summary>Get an update URL for an update key (if valid).</summary>
+ private readonly Func<string, string> GetUpdateUrl;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an empty instance.</summary>
+ public ModDatabase()
+ : this(new ModDataRecord[0], key => null) { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="records">The underlying mod data records indexed by default display name.</param>
+ /// <param name="getUpdateUrl">Get an update URL for an update key (if valid).</param>
+ public ModDatabase(IEnumerable<ModDataRecord> records, Func<string, string> getUpdateUrl)
+ {
+ this.Records = records.ToArray();
+ this.GetUpdateUrl = getUpdateUrl;
+ }
+
+ /// <summary>Get all mod data records.</summary>
+ public IEnumerable<ModDataRecord> GetAll()
+ {
+ return this.Records;
+ }
+
+ /// <summary>Get a mod data record.</summary>
+ /// <param name="modID">The unique mod ID.</param>
+ public ModDataRecord Get(string modID)
+ {
+ return !string.IsNullOrWhiteSpace(modID)
+ ? this.Records.FirstOrDefault(p => p.HasID(modID))
+ : null;
+ }
+
+ /// <summary>Get the mod page URL for a mod (if available).</summary>
+ /// <param name="id">The unique mod ID.</param>
+ public string GetModPageUrlFor(string id)
+ {
+ // get update key
+ ModDataRecord record = this.Get(id);
+ ModDataField updateKeyField = record?.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey);
+ if (updateKeyField == null)
+ return null;
+
+ // get update URL
+ return this.GetUpdateUrl(updateKeyField.Value);
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModStatus.cs b/src/SMAPI.Toolkit/Framework/ModData/ModStatus.cs
new file mode 100644
index 00000000..09da74bf
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ModData/ModStatus.cs
@@ -0,0 +1,18 @@
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>Indicates how SMAPI should treat a mod.</summary>
+ public enum ModStatus
+ {
+ /// <summary>Don't override the status.</summary>
+ None,
+
+ /// <summary>The mod is obsolete and shouldn't be used, regardless of version.</summary>
+ Obsolete,
+
+ /// <summary>Assume the mod is not compatible, even if SMAPI doesn't detect any incompatible code.</summary>
+ AssumeBroken,
+
+ /// <summary>Assume the mod is compatible, even if SMAPI detects incompatible code.</summary>
+ AssumeCompatible
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs b/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs
new file mode 100644
index 00000000..d61c427f
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs
@@ -0,0 +1,36 @@
+using System;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModData
+{
+ /// <summary>Indicates a detected non-error mod issue.</summary>
+ [Flags]
+ public enum ModWarning
+ {
+ /// <summary>No issues detected.</summary>
+ None = 0,
+
+ /// <summary>SMAPI detected incompatible code in the mod, but was configured to load it anyway.</summary>
+ BrokenCodeLoaded = 1,
+
+ /// <summary>The mod affects the save serializer in a way that may make saves unloadable without the mod.</summary>
+ ChangesSaveSerialiser = 2,
+
+ /// <summary>The mod patches the game in a way that may impact stability.</summary>
+ PatchesGame = 4,
+
+ /// <summary>The mod uses the <c>dynamic</c> keyword which won't work on Linux/Mac.</summary>
+ UsesDynamic = 8,
+
+ /// <summary>The mod references specialised 'unvalided update tick' events which may impact stability.</summary>
+ UsesUnvalidatedUpdateTick = 16,
+
+ /// <summary>The mod has no update keys set.</summary>
+ NoUpdateKeys = 32,
+
+ /// <summary>Uses .NET APIs for filesystem access.</summary>
+ AccessesFilesystem = 64,
+
+ /// <summary>Uses .NET APIs for shell or process access.</summary>
+ AccessesShell = 128
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs
new file mode 100644
index 00000000..bb467b36
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs
@@ -0,0 +1,64 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
+using StardewModdingAPI.Toolkit.Utilities;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModScanning
+{
+ /// <summary>The info about a mod read from its folder.</summary>
+ public class ModFolder
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A suggested display name for the mod folder.</summary>
+ public string DisplayName { get; }
+
+ /// <summary>The folder containing the mod's manifest.json.</summary>
+ public DirectoryInfo Directory { get; }
+
+ /// <summary>The mod manifest.</summary>
+ public Manifest Manifest { get; }
+
+ /// <summary>The error which occurred parsing the manifest, if any.</summary>
+ public string ManifestParseError { get; }
+
+ /// <summary>Whether the mod should be loaded by default. This is <c>false</c> if it was found within a folder whose name starts with a dot.</summary>
+ public bool ShouldBeLoaded { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="root">The root folder containing mods.</param>
+ /// <param name="directory">The folder containing the mod's manifest.json.</param>
+ /// <param name="manifest">The mod manifest.</param>
+ /// <param name="manifestParseError">The error which occurred parsing the manifest, if any.</param>
+ /// <param name="shouldBeLoaded">Whether the mod should be loaded by default. This should be <c>false</c> if it was found within a folder whose name starts with a dot.</param>
+ public ModFolder(DirectoryInfo root, DirectoryInfo directory, Manifest manifest, string manifestParseError = null, bool shouldBeLoaded = true)
+ {
+ // save info
+ this.Directory = directory;
+ this.Manifest = manifest;
+ this.ManifestParseError = manifestParseError;
+ this.ShouldBeLoaded = shouldBeLoaded;
+
+ // set display name
+ this.DisplayName = manifest?.Name;
+ if (string.IsNullOrWhiteSpace(this.DisplayName))
+ this.DisplayName = PathUtilities.GetRelativePath(root.FullName, directory.FullName);
+ }
+
+ /// <summary>Get the update keys for a mod.</summary>
+ /// <param name="manifest">The mod manifest.</param>
+ public IEnumerable<string> GetUpdateKeys(Manifest manifest)
+ {
+ return
+ (manifest.UpdateKeys ?? new string[0])
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .ToArray();
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
new file mode 100644
index 00000000..0ab73d56
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using StardewModdingAPI.Toolkit.Serialisation;
+using StardewModdingAPI.Toolkit.Serialisation.Models;
+
+namespace StardewModdingAPI.Toolkit.Framework.ModScanning
+{
+ /// <summary>Scans folders for mod data.</summary>
+ public class ModScanner
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The JSON helper with which to read manifests.</summary>
+ private readonly JsonHelper JsonHelper;
+
+ /// <summary>A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod.</summary>
+ private readonly HashSet<string> IgnoreFilesystemEntries = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase)
+ {
+ ".DS_Store",
+ "mcs",
+ "Thumbs.db"
+ };
+
+ /// <summary>The extensions for files which an XNB mod may contain. If a mod contains *only* these file extensions, it should be considered an XNB mod.</summary>
+ private readonly HashSet<string> PotentialXnbModExtensions = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase)
+ {
+ ".md",
+ ".png",
+ ".txt",
+ ".xnb"
+ };
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="jsonHelper">The JSON helper with which to read manifests.</param>
+ public ModScanner(JsonHelper jsonHelper)
+ {
+ this.JsonHelper = jsonHelper;
+ }
+
+ /// <summary>Extract information about all mods in the given folder.</summary>
+ /// <param name="rootPath">The root folder containing mods.</param>
+ public IEnumerable<ModFolder> GetModFolders(string rootPath)
+ {
+ DirectoryInfo root = new DirectoryInfo(rootPath);
+ return this.GetModFolders(root, root);
+ }
+
+ /// <summary>Extract information from a mod folder.</summary>
+ /// <param name="root">The root folder containing mods.</param>
+ /// <param name="searchFolder">The folder to search for a mod.</param>
+ public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder)
+ {
+ // find manifest.json
+ FileInfo manifestFile = this.FindManifest(searchFolder);
+
+ // set appropriate invalid-mod error
+ if (manifestFile == null)
+ {
+ FileInfo[] files = searchFolder.GetFiles("*", SearchOption.AllDirectories).Where(this.IsRelevant).ToArray();
+ if (!files.Any())
+ return new ModFolder(root, searchFolder, null, "it's an empty folder.");
+ if (files.All(file => this.PotentialXnbModExtensions.Contains(file.Extension)))
+ return new ModFolder(root, searchFolder, null, "it's not a SMAPI mod (see https://smapi.io/xnb for info).");
+ return new ModFolder(root, searchFolder, null, "it contains files, but none of them are manifest.json.");
+ }
+
+ // read mod info
+ Manifest manifest = null;
+ string manifestError = null;
+ {
+ try
+ {
+ if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest) || manifest == null)
+ manifestError = "its manifest is invalid.";
+ }
+ catch (SParseException ex)
+ {
+ manifestError = $"parsing its manifest failed: {ex.Message}";
+ }
+ catch (Exception ex)
+ {
+ manifestError = $"parsing its manifest failed:\n{ex}";
+ }
+ }
+
+ // normalise display fields
+ if (manifest != null)
+ {
+ manifest.Name = this.StripNewlines(manifest.Name);
+ manifest.Description = this.StripNewlines(manifest.Description);
+ manifest.Author = this.StripNewlines(manifest.Author);
+ }
+
+ return new ModFolder(root, manifestFile.Directory, manifest, manifestError);
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Recursively extract information about all mods in the given folder.</summary>
+ /// <param name="root">The root mod folder.</param>
+ /// <param name="folder">The folder to search for mods.</param>
+ public IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder)
+ {
+ // skip
+ if (folder.FullName != root.FullName && folder.Name.StartsWith("."))
+ yield return new ModFolder(root, folder, null, "ignored folder because its name starts with a dot.", shouldBeLoaded: false);
+
+ // recurse into subfolders
+ else if (this.IsModSearchFolder(root, folder))
+ {
+ foreach (DirectoryInfo subfolder in folder.EnumerateDirectories())
+ {
+ foreach (ModFolder match in this.GetModFolders(root, subfolder))
+ yield return match;
+ }
+ }
+
+ // treat as mod folder
+ else
+ yield return this.ReadFolder(root, folder);
+ }
+
+ /// <summary>Find the manifest for a mod folder.</summary>
+ /// <param name="folder">The folder to search.</param>
+ private FileInfo FindManifest(DirectoryInfo folder)
+ {
+ while (true)
+ {
+ // check for manifest in current folder
+ FileInfo file = new FileInfo(Path.Combine(folder.FullName, "manifest.json"));
+ if (file.Exists)
+ return file;
+
+ // check for single subfolder
+ FileSystemInfo[] entries = folder.EnumerateFileSystemInfos().Take(2).ToArray();
+ if (entries.Length == 1 && entries[0] is DirectoryInfo subfolder)
+ {
+ folder = subfolder;
+ continue;
+ }
+
+ // not found
+ return null;
+ }
+ }
+
+ /// <summary>Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods).</summary>
+ /// <param name="root">The root mod folder.</param>
+ /// <param name="folder">The folder to search for mods.</param>
+ private bool IsModSearchFolder(DirectoryInfo root, DirectoryInfo folder)
+ {
+ if (root.FullName == folder.FullName)
+ return true;
+
+ DirectoryInfo[] subfolders = folder.GetDirectories().Where(this.IsRelevant).ToArray();
+ FileInfo[] files = folder.GetFiles().Where(this.IsRelevant).ToArray();
+ return subfolders.Any() && !files.Any();
+ }
+
+ /// <summary>Get whether a file or folder is relevant when deciding how to process a mod folder.</summary>
+ /// <param name="entry">The file or folder.</param>
+ private bool IsRelevant(FileSystemInfo entry)
+ {
+ return !this.IgnoreFilesystemEntries.Contains(entry.Name);
+ }
+
+ /// <summary>Strip newlines from a string.</summary>
+ /// <param name="input">The input to strip.</param>
+ private string StripNewlines(string input)
+ {
+ return input?.Replace("\r", "").Replace("\n", "");
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs
new file mode 100644
index 00000000..f6c402d5
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs
@@ -0,0 +1,21 @@
+namespace StardewModdingAPI.Toolkit.Framework.UpdateData
+{
+ /// <summary>A mod repository which SMAPI can check for updates.</summary>
+ public enum ModRepositoryKey
+ {
+ /// <summary>An unknown or invalid mod repository.</summary>
+ Unknown,
+
+ /// <summary>The Chucklefish mod repository.</summary>
+ Chucklefish,
+
+ /// <summary>A GitHub project containing releases.</summary>
+ GitHub,
+
+ /// <summary>The ModDrop mod repository.</summary>
+ ModDrop,
+
+ /// <summary>The Nexus Mods mod repository.</summary>
+ Nexus
+ }
+}
diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs
new file mode 100644
index 00000000..865ebcf7
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs
@@ -0,0 +1,73 @@
+using System;
+
+namespace StardewModdingAPI.Toolkit.Framework.UpdateData
+{
+ /// <summary>A namespaced mod ID which uniquely identifies a mod within a mod repository.</summary>
+ public class UpdateKey
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The raw update key text.</summary>
+ public string RawText { get; }
+
+ /// <summary>The mod repository containing the mod.</summary>
+ public ModRepositoryKey Repository { get; }
+
+ /// <summary>The mod ID within the repository.</summary>
+ public string ID { get; }
+
+ /// <summary>Whether the update key seems to be valid.</summary>
+ public bool LooksValid { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="rawText">The raw update key text.</param>
+ /// <param name="repository">The mod repository containing the mod.</param>
+ /// <param name="id">The mod ID within the repository.</param>
+ public UpdateKey(string rawText, ModRepositoryKey repository, string id)
+ {
+ this.RawText = rawText;
+ this.Repository = repository;
+ this.ID = id;
+ this.LooksValid =
+ repository != ModRepositoryKey.Unknown
+ && !string.IsNullOrWhiteSpace(id);
+ }
+
+ /// <summary>Parse a raw update key.</summary>
+ /// <param name="raw">The raw update key to parse.</param>
+ public static UpdateKey Parse(string raw)
+ {
+ // split parts
+ string[] parts = raw?.Split(':');
+ if (parts == null || parts.Length != 2)
+ return new UpdateKey(raw, ModRepositoryKey.Unknown, null);
+
+ // extract parts
+ string repositoryKey = parts[0].Trim();
+ string id = parts[1].Trim();
+ if (string.IsNullOrWhiteSpace(id))
+ id = null;
+
+ // parse
+ if (!Enum.TryParse(repositoryKey, true, out ModRepositoryKey repository))
+ return new UpdateKey(raw, ModRepositoryKey.Unknown, id);
+ if (id == null)
+ return new UpdateKey(raw, repository, null);
+
+ return new UpdateKey(raw, repository, id);
+ }
+
+ /// <summary>Get a string that represents the current object.</summary>
+ public override string ToString()
+ {
+ return this.LooksValid
+ ? $"{this.Repository}:{this.ID}"
+ : this.RawText;
+ }
+ }
+}