summaryrefslogtreecommitdiff
path: root/src/StardewModdingAPI.Toolkit
diff options
context:
space:
mode:
Diffstat (limited to 'src/StardewModdingAPI.Toolkit')
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs6
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs29
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs230
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs161
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs36
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs24
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs48
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs18
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs9
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs29
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs84
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs18
-rw-r--r--src/StardewModdingAPI.Toolkit/Framework/UpdateData/UpdateKey.cs73
-rw-r--r--src/StardewModdingAPI.Toolkit/ModToolkit.cs6
-rw-r--r--src/StardewModdingAPI.Toolkit/SemanticVersion.cs39
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs4
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs17
-rw-r--r--src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj2
-rw-r--r--src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs20
19 files changed, 612 insertions, 241 deletions
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
index 2aafe199..8a9c0a25 100644
--- a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
@@ -18,9 +18,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
/// <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/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
index 21376b36..247730d7 100644
--- a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
@@ -13,6 +13,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
/*********
** 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];
@@ -34,6 +37,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
/// <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; }
@@ -42,6 +48,17 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
public string CompatibilitySummary { 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; }
+
+
/*********
** Public methods
*********/
@@ -51,20 +68,24 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
/// <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(WikiCompatibilityEntry wiki, ModDataRecord db)
+ public ModExtendedMetadataModel(WikiModEntry wiki, ModDataRecord db)
{
// wiki data
if (wiki != null)
{
this.ID = wiki.ID;
- this.Name = wiki.Name;
+ this.Name = wiki.Name.FirstOrDefault();
this.NexusID = wiki.NexusID;
this.ChucklefishID = wiki.ChucklefishID;
this.GitHubRepo = wiki.GitHubRepo;
this.CustomSourceUrl = wiki.CustomSourceUrl;
this.CustomUrl = wiki.CustomUrl;
- this.CompatibilityStatus = wiki.Status;
- this.CompatibilitySummary = wiki.Summary;
+
+ this.CompatibilityStatus = wiki.Compatibility.Status;
+ this.CompatibilitySummary = wiki.Compatibility.Summary;
+
+ this.BetaCompatibilityStatus = wiki.BetaCompatibility?.Status;
+ this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary;
}
// internal DB data
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs
new file mode 100644
index 00000000..7197bf2c
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs
@@ -0,0 +1,230 @@
+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
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <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:SMAPI_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;
+
+ // 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");
+ 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");
+
+ // parse stable compatibility
+ WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo
+ {
+ Status = this.GetAttributeAsStatus(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.GetAttributeAsStatus(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,
+ GitHubRepo = githubRepo,
+ CustomSourceUrl = customSourceUrl,
+ CustomUrl = customUrl,
+ 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 a compatibility status.</summary>
+ /// <param name="element">The element whose attributes to read.</param>
+ /// <param name="name">The attribute name.</param>
+ private WikiCompatibilityStatus? GetAttributeAsStatus(HtmlNode element, string name)
+ {
+ string raw = this.GetAttribute(element, name);
+ if (raw == null)
+ return null;
+ if (!Enum.TryParse(raw, true, out WikiCompatibilityStatus status))
+ throw new InvalidOperationException($"Unknown status '{raw}' when parsing compatibility list.");
+ return status;
+ }
+
+ /// <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/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs
deleted file mode 100644
index d0da42df..00000000
--- a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs
+++ /dev/null
@@ -1,161 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-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 compatibility list.</summary>
- public class WikiCompatibilityClient : IDisposable
- {
- /*********
- ** Properties
- *********/
- /// <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 WikiCompatibilityClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php")
- {
- this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
- }
-
- /// <summary>Fetch mod compatibility entries.</summary>
- public async Task<WikiCompatibilityEntry[]> FetchAsync()
- {
- // fetch HTML
- ResponseModel response = await this.Client
- .GetAsync("")
- .WithArguments(new
- {
- action = "parse",
- page = "Modding:SMAPI_compatibility",
- format = "json"
- })
- .As<ResponseModel>();
- string html = response.Parse.Text["*"];
-
- // parse HTML
- var doc = new HtmlDocument();
- doc.LoadHtml(html);
-
- // 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
- return this.ParseEntries(modNodes).ToArray();
- }
-
- /// <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<WikiCompatibilityEntry> ParseEntries(IEnumerable<HtmlNode> nodes)
- {
- foreach (HtmlNode node in nodes)
- {
- // parse status
- WikiCompatibilityStatus status;
- {
- string rawStatus = node.GetAttributeValue("data-status", null);
- if (rawStatus == null)
- continue; // not a mod node?
- if (!Enum.TryParse(rawStatus, true, out status))
- throw new InvalidOperationException($"Unknown status '{rawStatus}' when parsing compatibility list.");
- }
-
- // parse unofficial version
- ISemanticVersion unofficialVersion = null;
- {
- string rawUnofficialVersion = node.GetAttributeValue("data-unofficial-version", null);
- SemanticVersion.TryParse(rawUnofficialVersion, out unofficialVersion);
- }
-
- // parse other fields
- string name = node.Descendants("td").FirstOrDefault()?.InnerText?.Trim();
- string summary = node.Descendants("td").FirstOrDefault(p => p.GetAttributeValue("class", null) == "summary")?.InnerText.Trim();
- string[] ids = this.GetAttribute(node, "data-id")?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() ?? new string[0];
- int? nexusID = this.GetNullableIntAttribute(node, "data-nexus-id");
- int? chucklefishID = this.GetNullableIntAttribute(node, "data-chucklefish-id");
- string githubRepo = this.GetAttribute(node, "data-github");
- string customSourceUrl = this.GetAttribute(node, "data-custom-source");
- string customUrl = this.GetAttribute(node, "data-custom-url");
-
- // yield model
- yield return new WikiCompatibilityEntry
- {
- ID = ids,
- Name = name,
- Status = status,
- NexusID = nexusID,
- ChucklefishID = chucklefishID,
- GitHubRepo = githubRepo,
- CustomSourceUrl = customSourceUrl,
- CustomUrl = customUrl,
- UnofficialVersion = unofficialVersion,
- Summary = summary
- };
- }
- }
-
- /// <summary>Get a nullable integer attribute value.</summary>
- /// <param name="node">The HTML node.</param>
- /// <param name="attributeName">The attribute name.</param>
- private int? GetNullableIntAttribute(HtmlNode node, string attributeName)
- {
- string raw = this.GetAttribute(node, attributeName);
- if (raw != null && int.TryParse(raw, out int value))
- return value;
- return null;
- }
-
- /// <summary>Get a strings attribute value.</summary>
- /// <param name="node">The HTML node.</param>
- /// <param name="attributeName">The attribute name.</param>
- private string GetAttribute(HtmlNode node, string attributeName)
- {
- string raw = node.GetAttributeValue(attributeName, null);
- if (raw != null)
- raw = HtmlEntity.DeEntitize(raw);
- return raw;
- }
-
- /// <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/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs
deleted file mode 100644
index 8bc66e20..00000000
--- a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
-{
- /// <summary>An entry in the mod compatibility list.</summary>
- public class WikiCompatibilityEntry
- {
- /// <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; }
-
- /// <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 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 version of the latest unofficial update, if applicable.</summary>
- public ISemanticVersion UnofficialVersion { get; set; }
-
- /// <summary>The compatibility status.</summary>
- public WikiCompatibilityStatus Status { get; set; }
-
- /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatitng.</summary>
- public string Summary { get; set; }
- }
-}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs
new file mode 100644
index 00000000..204acd2b
--- /dev/null
+++ b/src/StardewModdingAPI.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/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs
new file mode 100644
index 00000000..ce8d6c5f
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs
@@ -0,0 +1,48 @@
+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 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 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/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs
new file mode 100644
index 00000000..0d614f28
--- /dev/null
+++ b/src/StardewModdingAPI.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/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs
index 82ac8837..3949f7dc 100644
--- a/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs
@@ -96,6 +96,15 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData
.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)
diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs
index 4aaa3f83..bb467b36 100644
--- a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using StardewModdingAPI.Toolkit.Serialisation.Models;
+using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Toolkit.Framework.ModScanning
{
@@ -11,11 +12,11 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
/*********
** Accessors
*********/
- /// <summary>The Mods subfolder containing this mod.</summary>
- public DirectoryInfo SearchDirectory { get; }
+ /// <summary>A suggested display name for the mod folder.</summary>
+ public string DisplayName { get; }
- /// <summary>The folder containing manifest.json.</summary>
- public DirectoryInfo ActualDirectory { get; }
+ /// <summary>The folder containing the mod's manifest.json.</summary>
+ public DirectoryInfo Directory { get; }
/// <summary>The mod manifest.</summary>
public Manifest Manifest { get; }
@@ -23,21 +24,31 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
/// <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="searchDirectory">The Mods subfolder containing this mod.</param>
- /// <param name="actualDirectory">The folder containing manifest.json.</param>
+ /// <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>
- public ModFolder(DirectoryInfo searchDirectory, DirectoryInfo actualDirectory, Manifest manifest, string manifestParseError = null)
+ /// <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)
{
- this.SearchDirectory = searchDirectory;
- this.ActualDirectory = actualDirectory;
+ // 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>
diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs
index f1cce4a4..106c294f 100644
--- a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs
+++ b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs
@@ -16,6 +16,23 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
/// <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
@@ -31,19 +48,28 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
/// <param name="rootPath">The root folder containing mods.</param>
public IEnumerable<ModFolder> GetModFolders(string rootPath)
{
- foreach (DirectoryInfo folder in new DirectoryInfo(rootPath).EnumerateDirectories())
- yield return this.ReadFolder(rootPath, folder);
+ DirectoryInfo root = new DirectoryInfo(rootPath);
+ return this.GetModFolders(root, root);
}
/// <summary>Extract information from a mod folder.</summary>
- /// <param name="rootPath">The root folder containing mods.</param>
+ /// <param name="root">The root folder containing mods.</param>
/// <param name="searchFolder">The folder to search for a mod.</param>
- public ModFolder ReadFolder(string rootPath, DirectoryInfo searchFolder)
+ public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder)
{
// find manifest.json
FileInfo manifestFile = this.FindManifest(searchFolder);
+
+ // set appropriate invalid-mod error
if (manifestFile == null)
- return new ModFolder(searchFolder, null, null, "it doesn't have a manifest.");
+ {
+ 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 an older XNB mod which replaces game files (not run through SMAPI).");
+ return new ModFolder(root, searchFolder, null, "it contains files, but none of them are manifest.json.");
+ }
// read mod info
Manifest manifest = null;
@@ -51,7 +77,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
{
try
{
- if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest))
+ if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest) || manifest == null)
manifestError = "its manifest is invalid.";
}
catch (SParseException ex)
@@ -64,13 +90,37 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
}
}
- return new ModFolder(searchFolder, manifestFile.Directory, manifest, manifestError);
+ 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)
@@ -94,5 +144,25 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning
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);
+ }
}
}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs b/src/StardewModdingAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs
new file mode 100644
index 00000000..7ca32f04
--- /dev/null
+++ b/src/StardewModdingAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs
@@ -0,0 +1,18 @@
+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 Nexus Mods mod repository.</summary>
+ Nexus
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/StardewModdingAPI.Toolkit/Framework/UpdateData/UpdateKey.cs
new file mode 100644
index 00000000..865ebcf7
--- /dev/null
+++ b/src/StardewModdingAPI.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;
+ }
+ }
+}
diff --git a/src/StardewModdingAPI.Toolkit/ModToolkit.cs b/src/StardewModdingAPI.Toolkit/ModToolkit.cs
index 8c78b2f3..c55f6c70 100644
--- a/src/StardewModdingAPI.Toolkit/ModToolkit.cs
+++ b/src/StardewModdingAPI.Toolkit/ModToolkit.cs
@@ -47,10 +47,10 @@ namespace StardewModdingAPI.Toolkit
}
/// <summary>Extract mod metadata from the wiki compatibility list.</summary>
- public async Task<WikiCompatibilityEntry[]> GetWikiCompatibilityListAsync()
+ public async Task<WikiModList> GetWikiCompatibilityListAsync()
{
- var client = new WikiCompatibilityClient(this.UserAgent);
- return await client.FetchAsync();
+ var client = new WikiClient(this.UserAgent);
+ return await client.FetchModsAsync();
}
/// <summary>Get SMAPI's internal mod database.</summary>
diff --git a/src/StardewModdingAPI.Toolkit/SemanticVersion.cs b/src/StardewModdingAPI.Toolkit/SemanticVersion.cs
index 156d58ce..a7990d13 100644
--- a/src/StardewModdingAPI.Toolkit/SemanticVersion.cs
+++ b/src/StardewModdingAPI.Toolkit/SemanticVersion.cs
@@ -4,7 +4,13 @@ using System.Text.RegularExpressions;
namespace StardewModdingAPI.Toolkit
{
/// <summary>A semantic version with an optional release tag.</summary>
- /// <remarks>The implementation is defined by Semantic Version 2.0 (http://semver.org/).</remarks>
+ /// <remarks>
+ /// The implementation is defined by Semantic Version 2.0 (https://semver.org/), with a few deviations:
+ /// - short-form "x.y" versions are supported (equivalent to "x.y.0");
+ /// - hyphens are synonymous with dots in prerelease tags (like "-unofficial.3-pathoschild");
+ /// - +build suffixes are not supported;
+ /// - and "-unofficial" in prerelease tags is always lower-precedence (e.g. "1.0-beta" is newer than "1.0-unofficial").
+ /// </remarks>
public class SemanticVersion : ISemanticVersion
{
/*********
@@ -17,13 +23,7 @@ namespace StardewModdingAPI.Toolkit
internal const string UnboundedVersionPattern = @"(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>" + SemanticVersion.TagPattern + "))?";
/// <summary>A regular expression matching a semantic version string.</summary>
- /// <remarks>
- /// This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>,
- /// with three important deviations intended to support Stardew Valley mod conventions:
- /// - allows short-form "x.y" versions;
- /// - allows hyphens in prerelease tags as synonyms for dots (like "-unofficial-update.3");
- /// - doesn't allow '+build' suffixes.
- /// </remarks>
+ /// <remarks>This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>, with deviations to support the Stardew Valley mod conventions (see remarks on <see cref="SemanticVersion"/>).</remarks>
internal static readonly Regex Regex = new Regex($@"^{SemanticVersion.UnboundedVersionPattern}$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture);
@@ -40,7 +40,14 @@ namespace StardewModdingAPI.Toolkit
public int PatchVersion { get; }
/// <summary>An optional prerelease tag.</summary>
- public string Build { get; }
+ [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")]
+ public string Build => this.PrereleaseTag;
+
+ /// <summary>An optional prerelease tag.</summary>
+ public string PrereleaseTag { get; }
+
+ /// <summary>Whether the version was parsed from the legacy object format.</summary>
+ public bool IsLegacyFormat { get; }
/*********
@@ -51,12 +58,14 @@ namespace StardewModdingAPI.Toolkit
/// <param name="minor">The minor version incremented for backwards-compatible changes.</param>
/// <param name="patch">The patch version for backwards-compatible fixes.</param>
/// <param name="tag">An optional prerelease tag.</param>
- public SemanticVersion(int major, int minor, int patch, string tag = null)
+ /// <param name="isLegacyFormat">Whether the version was parsed from the legacy object format.</param>
+ public SemanticVersion(int major, int minor, int patch, string tag = null, bool isLegacyFormat = false)
{
this.MajorVersion = major;
this.MinorVersion = minor;
this.PatchVersion = patch;
- this.Build = this.GetNormalisedTag(tag);
+ this.PrereleaseTag = this.GetNormalisedTag(tag);
+ this.IsLegacyFormat = isLegacyFormat;
this.AssertValid();
}
@@ -93,7 +102,7 @@ namespace StardewModdingAPI.Toolkit
this.MajorVersion = int.Parse(match.Groups["major"].Value);
this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0;
this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0;
- this.Build = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null;
+ this.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null;
this.AssertValid();
}
@@ -255,6 +264,12 @@ namespace StardewModdingAPI.Toolkit
// compare if different
if (curParts[i] != otherParts[i])
{
+ // unofficial is always lower-precedence
+ if (otherParts[i].Equals("unofficial", StringComparison.InvariantCultureIgnoreCase))
+ return curNewer;
+ if (curParts[i].Equals("unofficial", StringComparison.InvariantCultureIgnoreCase))
+ return curOlder;
+
// compare numerically if possible
{
if (int.TryParse(curParts[i], out int curNum) && int.TryParse(otherParts[i], out int otherNum))
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs
index 9b2f5e7d..e0e185c9 100644
--- a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs
@@ -70,7 +70,7 @@ namespace StardewModdingAPI.Toolkit.Serialisation.Converters
if (build == "0")
build = null; // '0' from incorrect examples in old SMAPI documentation
- return new SemanticVersion(major, minor, patch, build);
+ return new SemanticVersion(major, minor, patch, build, isLegacyFormat: true);
}
/// <summary>Read a JSON string.</summary>
@@ -82,7 +82,7 @@ namespace StardewModdingAPI.Toolkit.Serialisation.Converters
return null;
if (!SemanticVersion.TryParse(str, out ISemanticVersion version))
throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta (path: {path}).");
- return (SemanticVersion)version;
+ return version;
}
}
}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs
index cc8eeb73..cf2ce0d1 100644
--- a/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs
@@ -95,18 +95,14 @@ namespace StardewModdingAPI.Toolkit.Serialisation
Directory.CreateDirectory(dir);
// write file
- string json = JsonConvert.SerializeObject(model, this.JsonSettings);
+ string json = this.Serialise(model);
File.WriteAllText(fullPath, json);
}
-
- /*********
- ** Private methods
- *********/
/// <summary>Deserialize JSON text if possible.</summary>
/// <typeparam name="TModel">The model type.</typeparam>
/// <param name="json">The raw JSON text.</param>
- private TModel Deserialise<TModel>(string json)
+ public TModel Deserialise<TModel>(string json)
{
try
{
@@ -127,5 +123,14 @@ namespace StardewModdingAPI.Toolkit.Serialisation
throw;
}
}
+
+ /// <summary>Serialize a model to JSON text.</summary>
+ /// <typeparam name="TModel">The model type.</typeparam>
+ /// <param name="model">The model to serialise.</param>
+ /// <param name="formatting">The formatting to apply.</param>
+ public string Serialise<TModel>(TModel model, Formatting formatting = Formatting.Indented)
+ {
+ return JsonConvert.SerializeObject(model, formatting, this.JsonSettings);
+ }
}
}
diff --git a/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj b/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj
index 21c130b3..3fa28d19 100644
--- a/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj
+++ b/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj
@@ -12,7 +12,7 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="HtmlAgilityPack" Version="1.8.4" />
+ <PackageReference Include="HtmlAgilityPack" Version="1.8.9" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.2.0" />
</ItemGroup>
diff --git a/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs b/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs
index 2e74e7d9..79748c25 100644
--- a/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs
+++ b/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs
@@ -2,6 +2,7 @@ using System;
using System.Diagnostics.Contracts;
using System.IO;
using System.Linq;
+using System.Text.RegularExpressions;
namespace StardewModdingAPI.Toolkit.Utilities
{
@@ -61,5 +62,24 @@ namespace StardewModdingAPI.Toolkit.Utilities
relative = "./";
return relative;
}
+
+ /// <summary>Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain <c>../</c>).</summary>
+ /// <param name="path">The path to check.</param>
+ public static bool IsSafeRelativePath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ return true;
+
+ return
+ !Path.IsPathRooted(path)
+ && PathUtilities.GetSegments(path).All(segment => segment.Trim() != "..");
+ }
+
+ /// <summary>Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc).</summary>
+ /// <param name="str">The string to check.</param>
+ public static bool IsSlug(string str)
+ {
+ return !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase);
+ }
}
}