diff options
Diffstat (limited to 'src/SMAPI.Toolkit')
40 files changed, 2741 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; + } + } +} diff --git a/src/SMAPI.Toolkit/ModToolkit.cs b/src/SMAPI.Toolkit/ModToolkit.cs new file mode 100644 index 00000000..1b53e59e --- /dev/null +++ b/src/SMAPI.Toolkit/ModToolkit.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Framework.ModScanning; +using StardewModdingAPI.Toolkit.Serialisation; + +namespace StardewModdingAPI.Toolkit +{ + /// <summary>A convenience wrapper for the various tools.</summary> + public class ModToolkit + { + /********* + ** Fields + *********/ + /// <summary>The default HTTP user agent for the toolkit.</summary> + private readonly string UserAgent; + + /// <summary>Maps vendor keys (like <c>Nexus</c>) to their mod URL template (where <c>{0}</c> is the mod ID). This doesn't affect update checks, which defer to the remote web API.</summary> + private readonly IDictionary<string, string> VendorModUrls = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase) + { + ["Chucklefish"] = "https://community.playstarbound.com/resources/{0}", + ["GitHub"] = "https://github.com/{0}/releases", + ["Nexus"] = "https://www.nexusmods.com/stardewvalley/mods/{0}" + }; + + + /********* + ** Accessors + *********/ + /// <summary>Encapsulates SMAPI's JSON parsing.</summary> + public JsonHelper JsonHelper { get; } = new JsonHelper(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ModToolkit() + { + ISemanticVersion version = new SemanticVersion(this.GetType().Assembly.GetName().Version); + this.UserAgent = $"SMAPI Mod Handler Toolkit/{version}"; + } + + /// <summary>Extract mod metadata from the wiki compatibility list.</summary> + public async Task<WikiModList> GetWikiCompatibilityListAsync() + { + var client = new WikiClient(this.UserAgent); + return await client.FetchModsAsync(); + } + + /// <summary>Get SMAPI's internal mod database.</summary> + /// <param name="metadataPath">The file path for the SMAPI metadata file.</param> + public ModDatabase GetModDatabase(string metadataPath) + { + MetadataModel metadata = JsonConvert.DeserializeObject<MetadataModel>(File.ReadAllText(metadataPath)); + ModDataRecord[] records = metadata.ModData.Select(pair => new ModDataRecord(pair.Key, pair.Value)).ToArray(); + return new ModDatabase(records, this.GetUpdateUrl); + } + + /// <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) + { + return new ModScanner(this.JsonHelper).GetModFolders(rootPath); + } + + /// <summary>Get an update URL for an update key (if valid).</summary> + /// <param name="updateKey">The update key.</param> + public string GetUpdateUrl(string updateKey) + { + string[] parts = updateKey.Split(new[] { ':' }, 2); + if (parts.Length != 2) + return null; + + string vendorKey = parts[0].Trim(); + string modID = parts[1].Trim(); + + if (this.VendorModUrls.TryGetValue(vendorKey, out string urlTemplate)) + return string.Format(urlTemplate, modID); + + return null; + } + } +} diff --git a/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs b/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..1bb19e8c --- /dev/null +++ b/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using System.Reflection; +using System.Runtime.CompilerServices; + +[assembly: AssemblyTitle("SMAPI.Toolkit")] +[assembly: AssemblyDescription("A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods.")] +[assembly: InternalsVisibleTo("StardewModdingAPI")] +[assembly: InternalsVisibleTo("StardewModdingAPI.Web")] diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs new file mode 100644 index 00000000..ba9ca6c6 --- /dev/null +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -0,0 +1,312 @@ +using System; +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 (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 + { + /********* + ** Fields + *********/ + /// <summary>A regex pattern matching a valid prerelease tag.</summary> + internal const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+"; + + /// <summary>A regex pattern matching a version within a larger string.</summary> + 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 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); + + + /********* + ** Accessors + *********/ + /// <summary>The major version incremented for major API changes.</summary> + public int MajorVersion { get; } + + /// <summary>The minor version incremented for backwards-compatible changes.</summary> + public int MinorVersion { get; } + + /// <summary>The patch version for backwards-compatible bug fixes.</summary> + public int PatchVersion { get; } + + /// <summary>An optional prerelease tag.</summary> + public string PrereleaseTag { get; } + +#if !SMAPI_3_0_STRICT + /// <summary>An optional prerelease tag.</summary> + [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")] + public string Build => this.PrereleaseTag; + + /// <summary>Whether the version was parsed from the legacy object format.</summary> + public bool IsLegacyFormat { get; } +#endif + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="major">The major version incremented for major API changes.</param> + /// <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="prereleaseTag">An optional prerelease tag.</param> + /// <param name="isLegacyFormat">Whether the version was parsed from the legacy object format.</param> + public SemanticVersion(int major, int minor, int patch, string prereleaseTag = null +#if !SMAPI_3_0_STRICT + , bool isLegacyFormat = false +#endif + ) + { + this.MajorVersion = major; + this.MinorVersion = minor; + this.PatchVersion = patch; + this.PrereleaseTag = this.GetNormalisedTag(prereleaseTag); +#if !SMAPI_3_0_STRICT + this.IsLegacyFormat = isLegacyFormat; +#endif + + this.AssertValid(); + } + + /// <summary>Construct an instance.</summary> + /// <param name="version">The assembly version.</param> + /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception> + public SemanticVersion(Version version) + { + if (version == null) + throw new ArgumentNullException(nameof(version), "The input version can't be null."); + + this.MajorVersion = version.Major; + this.MinorVersion = version.Minor; + this.PatchVersion = version.Build; + + this.AssertValid(); + } + + /// <summary>Construct an instance.</summary> + /// <param name="version">The semantic version string.</param> + /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception> + /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception> + public SemanticVersion(string version) + { + // parse + if (version == null) + throw new ArgumentNullException(nameof(version), "The input version string can't be null."); + var match = SemanticVersion.Regex.Match(version.Trim()); + if (!match.Success) + throw new FormatException($"The input '{version}' isn't a valid semantic version."); + + // initialise + 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.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null; + + this.AssertValid(); + } + + /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception> + public int CompareTo(ISemanticVersion other) + { + if (other == null) + throw new ArgumentNullException(nameof(other)); + return this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, other.PrereleaseTag); + } + + /// <summary>Indicates whether the current object is equal to another object of the same type.</summary> + /// <returns>true if the current object is equal to the <paramref name="other" /> parameter; otherwise, false.</returns> + /// <param name="other">An object to compare with this object.</param> + public bool Equals(ISemanticVersion other) + { + return other != null && this.CompareTo(other) == 0; + } + + /// <summary>Whether this is a pre-release version.</summary> + public bool IsPrerelease() + { + return !string.IsNullOrWhiteSpace(this.PrereleaseTag); + } + + /// <summary>Get whether this version is older than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + public bool IsOlderThan(ISemanticVersion other) + { + return this.CompareTo(other) < 0; + } + + /// <summary>Get whether this version is older than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + public bool IsOlderThan(string other) + { + return this.IsOlderThan(new SemanticVersion(other)); + } + + /// <summary>Get whether this version is newer than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + public bool IsNewerThan(ISemanticVersion other) + { + return this.CompareTo(other) > 0; + } + + /// <summary>Get whether this version is newer than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + public bool IsNewerThan(string other) + { + return this.IsNewerThan(new SemanticVersion(other)); + } + + /// <summary>Get whether this version is between two specified versions (inclusively).</summary> + /// <param name="min">The minimum version.</param> + /// <param name="max">The maximum version.</param> + public bool IsBetween(ISemanticVersion min, ISemanticVersion max) + { + return this.CompareTo(min) >= 0 && this.CompareTo(max) <= 0; + } + + /// <summary>Get whether this version is between two specified versions (inclusively).</summary> + /// <param name="min">The minimum version.</param> + /// <param name="max">The maximum version.</param> + /// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception> + public bool IsBetween(string min, string max) + { + return this.IsBetween(new SemanticVersion(min), new SemanticVersion(max)); + } + + /// <summary>Get a string representation of the version.</summary> + public override string ToString() + { + // version + string result = this.PatchVersion != 0 + ? $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}" + : $"{this.MajorVersion}.{this.MinorVersion}"; + + // tag + string tag = this.PrereleaseTag; + if (tag != null) + result += $"-{tag}"; + return result; + } + + /// <summary>Parse a version string without throwing an exception if it fails.</summary> + /// <param name="version">The version string.</param> + /// <param name="parsed">The parsed representation.</param> + /// <returns>Returns whether parsing the version succeeded.</returns> + public static bool TryParse(string version, out ISemanticVersion parsed) + { + try + { + parsed = new SemanticVersion(version); + return true; + } + catch + { + parsed = null; + return false; + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Get a normalised build tag.</summary> + /// <param name="tag">The tag to normalise.</param> + private string GetNormalisedTag(string tag) + { + tag = tag?.Trim(); + return !string.IsNullOrWhiteSpace(tag) ? tag : null; + } + + /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary> + /// <param name="otherMajor">The major version to compare with this instance.</param> + /// <param name="otherMinor">The minor version to compare with this instance.</param> + /// <param name="otherPatch">The patch version to compare with this instance.</param> + /// <param name="otherTag">The prerelease tag to compare with this instance.</param> + private int CompareTo(int otherMajor, int otherMinor, int otherPatch, string otherTag) + { + const int same = 0; + const int curNewer = 1; + const int curOlder = -1; + + // compare stable versions + if (this.MajorVersion != otherMajor) + return this.MajorVersion.CompareTo(otherMajor); + if (this.MinorVersion != otherMinor) + return this.MinorVersion.CompareTo(otherMinor); + if (this.PatchVersion != otherPatch) + return this.PatchVersion.CompareTo(otherPatch); + if (this.PrereleaseTag == otherTag) + return same; + + // stable supercedes pre-release + bool curIsStable = string.IsNullOrWhiteSpace(this.PrereleaseTag); + bool otherIsStable = string.IsNullOrWhiteSpace(otherTag); + if (curIsStable) + return curNewer; + if (otherIsStable) + return curOlder; + + // compare two pre-release tag values + string[] curParts = this.PrereleaseTag.Split('.', '-'); + string[] otherParts = otherTag.Split('.', '-'); + for (int i = 0; i < curParts.Length; i++) + { + // longer prerelease tag supercedes if otherwise equal + if (otherParts.Length <= i) + return curNewer; + + // 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)) + return curNum.CompareTo(otherNum); + } + + // else compare lexically + return string.Compare(curParts[i], otherParts[i], StringComparison.OrdinalIgnoreCase); + } + } + + // fallback (this should never happen) + return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase); + } + + /// <summary>Assert that the current version is valid.</summary> + private void AssertValid() + { + if (this.MajorVersion < 0 || this.MinorVersion < 0 || this.PatchVersion < 0) + throw new FormatException($"{this} isn't a valid semantic version. The major, minor, and patch numbers can't be negative."); + if (this.MajorVersion == 0 && this.MinorVersion == 0 && this.PatchVersion == 0) + throw new FormatException($"{this} isn't a valid semantic version. At least one of the major, minor, and patch numbers must be more than zero."); + if (this.PrereleaseTag != null) + { + if (this.PrereleaseTag.Trim() == "") + throw new FormatException($"{this} isn't a valid semantic version. The tag cannot be a blank string (but may be omitted)."); + if (!Regex.IsMatch(this.PrereleaseTag, $"^{SemanticVersion.TagPattern}$", RegexOptions.IgnoreCase)) + throw new FormatException($"{this} isn't a valid semantic version. The tag is invalid."); + } + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs b/src/SMAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs new file mode 100644 index 00000000..232c22a7 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs @@ -0,0 +1,50 @@ +using System; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Serialisation.Models; + +namespace StardewModdingAPI.Toolkit.Serialisation.Converters +{ + /// <summary>Handles deserialisation of <see cref="ManifestContentPackFor"/> arrays.</summary> + public class ManifestContentPackForConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// <summary>Whether this converter can write JSON.</summary> + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can convert the specified object type.</summary> + /// <param name="objectType">The object type.</param> + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ManifestContentPackFor[]); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Read the JSON representation of the object.</summary> + /// <param name="reader">The JSON reader.</param> + /// <param name="objectType">The object type.</param> + /// <param name="existingValue">The object being read.</param> + /// <param name="serializer">The calling serializer.</param> + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return serializer.Deserialize<ManifestContentPackFor>(reader); + } + + /// <summary>Writes the JSON representation of the object.</summary> + /// <param name="writer">The JSON writer.</param> + /// <param name="value">The value.</param> + /// <param name="serializer">The calling serializer.</param> + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs b/src/SMAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs new file mode 100644 index 00000000..0a304ee3 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Toolkit.Serialisation.Models; + +namespace StardewModdingAPI.Toolkit.Serialisation.Converters +{ + /// <summary>Handles deserialisation of <see cref="ManifestDependency"/> arrays.</summary> + internal class ManifestDependencyArrayConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// <summary>Whether this converter can write JSON.</summary> + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can convert the specified object type.</summary> + /// <param name="objectType">The object type.</param> + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ManifestDependency[]); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Read the JSON representation of the object.</summary> + /// <param name="reader">The JSON reader.</param> + /// <param name="objectType">The object type.</param> + /// <param name="existingValue">The object being read.</param> + /// <param name="serializer">The calling serializer.</param> + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + List<ManifestDependency> result = new List<ManifestDependency>(); + foreach (JObject obj in JArray.Load(reader).Children<JObject>()) + { + string uniqueID = obj.ValueIgnoreCase<string>(nameof(ManifestDependency.UniqueID)); + string minVersion = obj.ValueIgnoreCase<string>(nameof(ManifestDependency.MinimumVersion)); + bool required = obj.ValueIgnoreCase<bool?>(nameof(ManifestDependency.IsRequired)) ?? true; + result.Add(new ManifestDependency(uniqueID, minVersion, required)); + } + return result.ToArray(); + } + + /// <summary>Writes the JSON representation of the object.</summary> + /// <param name="writer">The JSON writer.</param> + /// <param name="value">The value.</param> + /// <param name="serializer">The calling serializer.</param> + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs new file mode 100644 index 00000000..aca06849 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs @@ -0,0 +1,98 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace StardewModdingAPI.Toolkit.Serialisation.Converters +{ + /// <summary>Handles deserialisation of <see cref="ISemanticVersion"/>.</summary> + internal class SemanticVersionConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// <summary>Get whether this converter can read JSON.</summary> + public override bool CanRead => true; + + /// <summary>Get whether this converter can write JSON.</summary> + public override bool CanWrite => true; + + + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can convert the specified object type.</summary> + /// <param name="objectType">The object type.</param> + public override bool CanConvert(Type objectType) + { + return typeof(ISemanticVersion).IsAssignableFrom(objectType); + } + + /// <summary>Reads the JSON representation of the object.</summary> + /// <param name="reader">The JSON reader.</param> + /// <param name="objectType">The object type.</param> + /// <param name="existingValue">The object being read.</param> + /// <param name="serializer">The calling serializer.</param> + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + string path = reader.Path; + switch (reader.TokenType) + { + case JsonToken.StartObject: + return this.ReadObject(JObject.Load(reader)); + case JsonToken.String: + return this.ReadString(JToken.Load(reader).Value<string>(), path); + default: + throw new SParseException($"Can't parse {nameof(ISemanticVersion)} from {reader.TokenType} node (path: {reader.Path})."); + } + } + + /// <summary>Writes the JSON representation of the object.</summary> + /// <param name="writer">The JSON writer.</param> + /// <param name="value">The value.</param> + /// <param name="serializer">The calling serializer.</param> + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(value?.ToString()); + } + + + /********* + ** Private methods + *********/ + /// <summary>Read a JSON object.</summary> + /// <param name="obj">The JSON object to read.</param> + private ISemanticVersion ReadObject(JObject obj) + { + int major = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.MajorVersion)); + int minor = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.MinorVersion)); + int patch = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.PatchVersion)); + string prereleaseTag = obj.ValueIgnoreCase<string>(nameof(ISemanticVersion.PrereleaseTag)); +#if !SMAPI_3_0_STRICT + if (string.IsNullOrWhiteSpace(prereleaseTag)) + { + prereleaseTag = obj.ValueIgnoreCase<string>("Build"); + if (prereleaseTag == "0") + prereleaseTag = null; // '0' from incorrect examples in old SMAPI documentation + } +#endif + + return new SemanticVersion(major, minor, patch, prereleaseTag +#if !SMAPI_3_0_STRICT + , isLegacyFormat: true +#endif + ); + } + + /// <summary>Read a JSON string.</summary> + /// <param name="str">The JSON string value.</param> + /// <param name="path">The path to the current JSON node.</param> + private ISemanticVersion ReadString(string str, string path) + { + if (string.IsNullOrWhiteSpace(str)) + 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 version; + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs b/src/SMAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs new file mode 100644 index 00000000..5e0b0f4a --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs @@ -0,0 +1,76 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace StardewModdingAPI.Toolkit.Serialisation.Converters +{ + /// <summary>The base implementation for simplified converters which deserialise <typeparamref name="T"/> without overriding serialisation.</summary> + /// <typeparam name="T">The type to deserialise.</typeparam> + internal abstract class SimpleReadOnlyConverter<T> : JsonConverter + { + /********* + ** Accessors + *********/ + /// <summary>Whether this converter can write JSON.</summary> + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can convert the specified object type.</summary> + /// <param name="objectType">The object type.</param> + public override bool CanConvert(Type objectType) + { + return objectType == typeof(T); + } + + /// <summary>Writes the JSON representation of the object.</summary> + /// <param name="writer">The JSON writer.</param> + /// <param name="value">The value.</param> + /// <param name="serializer">The calling serializer.</param> + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + + /// <summary>Reads the JSON representation of the object.</summary> + /// <param name="reader">The JSON reader.</param> + /// <param name="objectType">The object type.</param> + /// <param name="existingValue">The object being read.</param> + /// <param name="serializer">The calling serializer.</param> + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + string path = reader.Path; + switch (reader.TokenType) + { + case JsonToken.StartObject: + return this.ReadObject(JObject.Load(reader), path); + case JsonToken.String: + return this.ReadString(JToken.Load(reader).Value<string>(), path); + default: + throw new SParseException($"Can't parse {typeof(T).Name} from {reader.TokenType} node (path: {reader.Path})."); + } + } + + + /********* + ** Protected methods + *********/ + /// <summary>Read a JSON object.</summary> + /// <param name="obj">The JSON object to read.</param> + /// <param name="path">The path to the current JSON node.</param> + protected virtual T ReadObject(JObject obj, string path) + { + throw new SParseException($"Can't parse {typeof(T).Name} from object node (path: {path})."); + } + + /// <summary>Read a JSON string.</summary> + /// <param name="str">The JSON string value.</param> + /// <param name="path">The path to the current JSON node.</param> + protected virtual T ReadString(string str, string path) + { + throw new SParseException($"Can't parse {typeof(T).Name} from string node (path: {path})."); + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/InternalExtensions.cs b/src/SMAPI.Toolkit/Serialisation/InternalExtensions.cs new file mode 100644 index 00000000..12b2c933 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/InternalExtensions.cs @@ -0,0 +1,21 @@ +using System; +using Newtonsoft.Json.Linq; + +namespace StardewModdingAPI.Toolkit.Serialisation +{ + /// <summary>Provides extension methods for parsing JSON.</summary> + public static class JsonExtensions + { + /// <summary>Get a JSON field value from a case-insensitive field name. This will check for an exact match first, then search without case sensitivity.</summary> + /// <typeparam name="T">The value type.</typeparam> + /// <param name="obj">The JSON object to search.</param> + /// <param name="fieldName">The field name.</param> + public static T ValueIgnoreCase<T>(this JObject obj, string fieldName) + { + JToken token = obj.GetValue(fieldName, StringComparison.InvariantCultureIgnoreCase); + return token != null + ? token.Value<T>() + : default(T); + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/JsonHelper.cs b/src/SMAPI.Toolkit/Serialisation/JsonHelper.cs new file mode 100644 index 00000000..cf2ce0d1 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/JsonHelper.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using StardewModdingAPI.Toolkit.Serialisation.Converters; + +namespace StardewModdingAPI.Toolkit.Serialisation +{ + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + public class JsonHelper + { + /********* + ** Accessors + *********/ + /// <summary>The JSON settings to use when serialising and deserialising files.</summary> + public JsonSerializerSettings JsonSettings { get; } = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection<T> values are duplicated each time the config is loaded + Converters = new List<JsonConverter> + { + new SemanticVersionConverter(), + new StringEnumConverter() + } + }; + + + /********* + ** Public methods + *********/ + /// <summary>Read a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="fullPath">The absolete file path.</param> + /// <param name="result">The parsed content model.</param> + /// <returns>Returns false if the file doesn't exist, else true.</returns> + /// <exception cref="ArgumentException">The given <paramref name="fullPath"/> is empty or invalid.</exception> + /// <exception cref="JsonReaderException">The file contains invalid JSON.</exception> + public bool ReadJsonFileIfExists<TModel>(string fullPath, out TModel result) + { + // validate + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + + // read file + string json; + try + { + json = File.ReadAllText(fullPath); + } + catch (Exception ex) when (ex is DirectoryNotFoundException || ex is FileNotFoundException) + { + result = default(TModel); + return false; + } + + // deserialise model + try + { + result = this.Deserialise<TModel>(json); + return true; + } + catch (Exception ex) + { + string error = $"Can't parse JSON file at {fullPath}."; + + if (ex is JsonReaderException) + { + error += " This doesn't seem to be valid JSON."; + if (json.Contains("“") || json.Contains("”")) + error += " Found curly quotes in the text; note that only straight quotes are allowed in JSON."; + } + error += $"\nTechnical details: {ex.Message}"; + throw new JsonReaderException(error); + } + } + + /// <summary>Save to a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="fullPath">The absolete file path.</param> + /// <param name="model">The model to save.</param> + /// <exception cref="InvalidOperationException">The given path is empty or invalid.</exception> + public void WriteJsonFile<TModel>(string fullPath, TModel model) + where TModel : class + { + // validate + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + + // create directory if needed + string dir = Path.GetDirectoryName(fullPath); + if (dir == null) + throw new ArgumentException("The file path is invalid.", nameof(fullPath)); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + // write file + string json = this.Serialise(model); + File.WriteAllText(fullPath, json); + } + + /// <summary>Deserialize JSON text if possible.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="json">The raw JSON text.</param> + public TModel Deserialise<TModel>(string json) + { + try + { + return JsonConvert.DeserializeObject<TModel>(json, this.JsonSettings); + } + catch (JsonReaderException) + { + // try replacing curly quotes + if (json.Contains("“") || json.Contains("”")) + { + try + { + return JsonConvert.DeserializeObject<TModel>(json.Replace('“', '"').Replace('”', '"'), this.JsonSettings); + } + catch { /* rethrow original error */ } + } + + 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/SMAPI.Toolkit/Serialisation/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialisation/Models/Manifest.cs new file mode 100644 index 00000000..6cb9496b --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Models/Manifest.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Serialisation.Converters; + +namespace StardewModdingAPI.Toolkit.Serialisation.Models +{ + /// <summary>A manifest which describes a mod for SMAPI.</summary> + public class Manifest : IManifest + { + /********* + ** Accessors + *********/ + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>A brief description of the mod.</summary> + public string Description { get; set; } + + /// <summary>The mod author's name.</summary> + public string Author { get; set; } + + /// <summary>The mod version.</summary> + public ISemanticVersion Version { get; set; } + + /// <summary>The minimum SMAPI version required by this mod, if any.</summary> + public ISemanticVersion MinimumApiVersion { get; set; } + + /// <summary>The name of the DLL in the directory that has the <c>Entry</c> method. Mutually exclusive with <see cref="ContentPackFor"/>.</summary> + public string EntryDll { get; set; } + + /// <summary>The mod which will read this as a content pack. Mutually exclusive with <see cref="Manifest.EntryDll"/>.</summary> + [JsonConverter(typeof(ManifestContentPackForConverter))] + public IManifestContentPackFor ContentPackFor { get; set; } + + /// <summary>The other mods that must be loaded before this mod.</summary> + [JsonConverter(typeof(ManifestDependencyArrayConverter))] + public IManifestDependency[] Dependencies { get; set; } + + /// <summary>The namespaced mod IDs to query for updates (like <c>Nexus:541</c>).</summary> + public string[] UpdateKeys { get; set; } + + /// <summary>The unique mod ID.</summary> + public string UniqueID { get; set; } + + /// <summary>Any manifest fields which didn't match a valid field.</summary> + [JsonExtensionData] + public IDictionary<string, object> ExtraFields { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public Manifest() { } + + /// <summary>Construct an instance for a transitional content pack.</summary> + /// <param name="uniqueID">The unique mod ID.</param> + /// <param name="name">The mod name.</param> + /// <param name="author">The mod author's name.</param> + /// <param name="description">A brief description of the mod.</param> + /// <param name="version">The mod version.</param> + /// <param name="contentPackFor">The modID which will read this as a content pack.</param> + public Manifest(string uniqueID, string name, string author, string description, ISemanticVersion version, string contentPackFor = null) + { + this.Name = name; + this.Author = author; + this.Description = description; + this.Version = version; + this.UniqueID = uniqueID; + this.UpdateKeys = new string[0]; + this.ContentPackFor = new ManifestContentPackFor { UniqueID = contentPackFor }; + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs b/src/SMAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs new file mode 100644 index 00000000..d0e42216 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Toolkit.Serialisation.Models +{ + /// <summary>Indicates which mod can read the content pack represented by the containing manifest.</summary> + public class ManifestContentPackFor : IManifestContentPackFor + { + /********* + ** Accessors + *********/ + /// <summary>The unique ID of the mod which can read this content pack.</summary> + public string UniqueID { get; set; } + + /// <summary>The minimum required version (if any).</summary> + public ISemanticVersion MinimumVersion { get; set; } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/Models/ManifestDependency.cs b/src/SMAPI.Toolkit/Serialisation/Models/ManifestDependency.cs new file mode 100644 index 00000000..8db58d5d --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/Models/ManifestDependency.cs @@ -0,0 +1,35 @@ +namespace StardewModdingAPI.Toolkit.Serialisation.Models +{ + /// <summary>A mod dependency listed in a mod manifest.</summary> + public class ManifestDependency : IManifestDependency + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod ID to require.</summary> + public string UniqueID { get; set; } + + /// <summary>The minimum required version (if any).</summary> + public ISemanticVersion MinimumVersion { get; set; } + + /// <summary>Whether the dependency must be installed to use the mod.</summary> + public bool IsRequired { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="uniqueID">The unique mod ID to require.</param> + /// <param name="minimumVersion">The minimum required version (if any).</param> + /// <param name="required">Whether the dependency must be installed to use the mod.</param> + public ManifestDependency(string uniqueID, string minimumVersion, bool required = true) + { + this.UniqueID = uniqueID; + this.MinimumVersion = !string.IsNullOrWhiteSpace(minimumVersion) + ? new SemanticVersion(minimumVersion) + : null; + this.IsRequired = required; + } + } +} diff --git a/src/SMAPI.Toolkit/Serialisation/SParseException.cs b/src/SMAPI.Toolkit/Serialisation/SParseException.cs new file mode 100644 index 00000000..61a7b305 --- /dev/null +++ b/src/SMAPI.Toolkit/Serialisation/SParseException.cs @@ -0,0 +1,17 @@ +using System; + +namespace StardewModdingAPI.Toolkit.Serialisation +{ + /// <summary>A format exception which provides a user-facing error message.</summary> + internal class SParseException : FormatException + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="message">The error message.</param> + /// <param name="ex">The underlying exception, if any.</param> + public SParseException(string message, Exception ex = null) + : base(message, ex) { } + } +} diff --git a/src/SMAPI.Toolkit/StardewModdingAPI.Toolkit.csproj b/src/SMAPI.Toolkit/StardewModdingAPI.Toolkit.csproj new file mode 100644 index 00000000..46d38f17 --- /dev/null +++ b/src/SMAPI.Toolkit/StardewModdingAPI.Toolkit.csproj @@ -0,0 +1,28 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + <OutputPath>..\..\bin\$(Configuration)\SMAPI.Toolkit</OutputPath> + <DocumentationFile>..\..\bin\$(Configuration)\SMAPI.Toolkit\$(TargetFramework)\StardewModdingAPI.Toolkit.xml</DocumentationFile> + <LangVersion>latest</LangVersion> + <PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget> + </PropertyGroup> + + <ItemGroup> + <Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" /> + </ItemGroup> + + <ItemGroup> + <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> + + <ItemGroup> + <ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj" /> + </ItemGroup> + + <Import Project="..\..\build\common.targets" /> + +</Project> diff --git a/src/SMAPI.Toolkit/Utilities/FileUtilities.cs b/src/SMAPI.Toolkit/Utilities/FileUtilities.cs new file mode 100644 index 00000000..7856fdb1 --- /dev/null +++ b/src/SMAPI.Toolkit/Utilities/FileUtilities.cs @@ -0,0 +1,46 @@ +using System.IO; +using System.Threading; + +namespace StardewModdingAPI.Toolkit.Utilities +{ + /// <summary>Provides utilities for dealing with files.</summary> + public static class FileUtilities + { + /********* + ** Public methods + *********/ + /// <summary>Delete a file or folder regardless of file permissions, and block until deletion completes.</summary> + /// <param name="entry">The file or folder to reset.</param> + public static void ForceDelete(FileSystemInfo entry) + { + // ignore if already deleted + entry.Refresh(); + if (!entry.Exists) + return; + + // delete children + if (entry is DirectoryInfo folder) + { + foreach (FileSystemInfo child in folder.GetFileSystemInfos()) + FileUtilities.ForceDelete(child); + } + + // reset permissions & delete + entry.Attributes = FileAttributes.Normal; + entry.Delete(); + + // wait for deletion to finish + for (int i = 0; i < 10; i++) + { + entry.Refresh(); + if (entry.Exists) + Thread.Sleep(500); + } + + // throw exception if deletion didn't happen before timeout + entry.Refresh(); + if (entry.Exists) + throw new IOException($"Timed out trying to delete {entry.FullName}"); + } + } +} diff --git a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs new file mode 100644 index 00000000..8a3c2b03 --- /dev/null +++ b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs @@ -0,0 +1,85 @@ +using System; +using System.Diagnostics.Contracts; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace StardewModdingAPI.Toolkit.Utilities +{ + /// <summary>Provides utilities for normalising file paths.</summary> + public static class PathUtilities + { + /********* + ** Fields + *********/ + /// <summary>The possible directory separator characters in a file path.</summary> + private static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); + + /// <summary>The preferred directory separator chaeacter in an asset key.</summary> + private static readonly string PreferredPathSeparator = Path.DirectorySeparatorChar.ToString(); + + + /********* + ** Public methods + *********/ + /// <summary>Get the segments from a path (e.g. <c>/usr/bin/boop</c> => <c>usr</c>, <c>bin</c>, and <c>boop</c>).</summary> + /// <param name="path">The path to split.</param> + /// <param name="limit">The number of segments to match. Any additional segments will be merged into the last returned part.</param> + public static string[] GetSegments(string path, int? limit = null) + { + return limit.HasValue + ? path.Split(PathUtilities.PossiblePathSeparators, limit.Value, StringSplitOptions.RemoveEmptyEntries) + : path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); + } + + /// <summary>Normalise path separators in a file path.</summary> + /// <param name="path">The file path to normalise.</param> + [Pure] + public static string NormalisePathSeparators(string path) + { + string[] parts = PathUtilities.GetSegments(path); + string normalised = string.Join(PathUtilities.PreferredPathSeparator, parts); + if (path.StartsWith(PathUtilities.PreferredPathSeparator)) + normalised = PathUtilities.PreferredPathSeparator + normalised; // keep root slash + return normalised; + } + + /// <summary>Get a directory or file path relative to a given source path.</summary> + /// <param name="sourceDir">The source folder path.</param> + /// <param name="targetPath">The target folder or file path.</param> + [Pure] + public static string GetRelativePath(string sourceDir, string targetPath) + { + // convert to URIs + Uri from = new Uri(sourceDir.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + Uri to = new Uri(targetPath.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + if (from.Scheme != to.Scheme) + throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{sourceDir}'."); + + // get relative path + string relative = PathUtilities.NormalisePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())); + if (relative == "") + 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); + } + } +} |