From 125bcbee56bf40cf82abc7fdb502f8cbc18546cf Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 13 Sep 2019 17:22:45 -0400 Subject: migrate to new project file format --- .../Framework/Clients/WebApi/ModEntryModel.cs | 33 +++ .../Clients/WebApi/ModEntryVersionModel.cs | 31 +++ .../Clients/WebApi/ModExtendedMetadataModel.cs | 123 +++++++++++ .../Framework/Clients/WebApi/ModSeachModel.cs | 36 ++++ .../Clients/WebApi/ModSearchEntryModel.cs | 34 +++ .../Framework/Clients/WebApi/WebApiClient.cs | 73 +++++++ .../Framework/Clients/Wiki/WikiClient.cs | 237 +++++++++++++++++++++ .../Clients/Wiki/WikiCompatibilityInfo.cs | 24 +++ .../Clients/Wiki/WikiCompatibilityStatus.cs | 27 +++ .../Framework/Clients/Wiki/WikiModEntry.cs | 54 +++++ .../Framework/Clients/Wiki/WikiModList.cs | 18 ++ .../Framework/ModData/MetadataModel.cs | 14 ++ .../Framework/ModData/ModDataField.cs | 82 +++++++ .../Framework/ModData/ModDataFieldKey.cs | 18 ++ .../Framework/ModData/ModDataModel.cs | 127 +++++++++++ .../Framework/ModData/ModDataRecord.cs | 147 +++++++++++++ .../ModData/ModDataRecordVersionedFields.cs | 54 +++++ src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs | 65 ++++++ src/SMAPI.Toolkit/Framework/ModData/ModStatus.cs | 18 ++ src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs | 36 ++++ .../Framework/ModScanning/ModFolder.cs | 64 ++++++ .../Framework/ModScanning/ModScanner.cs | 183 ++++++++++++++++ .../Framework/UpdateData/ModRepositoryKey.cs | 21 ++ .../Framework/UpdateData/UpdateKey.cs | 73 +++++++ 24 files changed, 1592 insertions(+) create mode 100644 src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs create mode 100644 src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs create mode 100644 src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs create mode 100644 src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs create mode 100644 src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs create mode 100644 src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs create mode 100644 src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs create mode 100644 src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs create mode 100644 src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs create mode 100644 src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs create mode 100644 src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModData/ModDataFieldKey.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModData/ModStatus.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs create mode 100644 src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs create mode 100644 src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs create mode 100644 src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs (limited to 'src/SMAPI.Toolkit/Framework') 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 +{ + /// Metadata about a mod. + public class ModEntryModel + { + /********* + ** Accessors + *********/ + /// The mod's unique ID (if known). + public string ID { get; set; } + + /// The main version. + public ModEntryVersionModel Main { get; set; } + + /// The latest optional version, if newer than . + public ModEntryVersionModel Optional { get; set; } + + /// The latest unofficial version, if newer than and . + public ModEntryVersionModel Unofficial { get; set; } + + /// The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see ). + public ModEntryVersionModel UnofficialForBeta { get; set; } + + /// Optional extended data which isn't needed for update checks. + public ModExtendedMetadataModel Metadata { get; set; } + + /// Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, should be used for beta versions of SMAPI instead of . + public bool HasBetaInfo { get; set; } + + /// The errors that occurred while fetching update data. + 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 +{ + /// Metadata about a version. + public class ModEntryVersionModel + { + /********* + ** Accessors + *********/ + /// The version number. + public ISemanticVersion Version { get; set; } + + /// The mod page URL. + public string Url { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public ModEntryVersionModel() { } + + /// Construct an instance. + /// The version number. + /// The mod page URL. + 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 +{ + /// Extended metadata about a mod. + public class ModExtendedMetadataModel + { + /********* + ** Accessors + *********/ + /**** + ** Mod info + ****/ + /// The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates). + public string[] ID { get; set; } = new string[0]; + + /// The mod's display name. + public string Name { get; set; } + + /// The mod ID on Nexus. + public int? NexusID { get; set; } + + /// The mod ID in the Chucklefish mod repo. + public int? ChucklefishID { get; set; } + + /// The mod ID in the ModDrop mod repo. + public int? ModDropID { get; set; } + + /// The GitHub repository in the form 'owner/repo'. + public string GitHubRepo { get; set; } + + /// The URL to a non-GitHub source repo. + public string CustomSourceUrl { get; set; } + + /// The custom mod page URL (if applicable). + public string CustomUrl { get; set; } + + + /**** + ** Stable compatibility + ****/ + /// The compatibility status. + [JsonConverter(typeof(StringEnumConverter))] + public WikiCompatibilityStatus? CompatibilityStatus { get; set; } + + /// The human-readable summary of the compatibility status or workaround, without HTML formatitng. + public string CompatibilitySummary { get; set; } + + /// The game or SMAPI version which broke this mod, if applicable. + public string BrokeIn { get; set; } + + + /**** + ** Beta compatibility + ****/ + /// The compatibility status for the Stardew Valley beta (if any). + [JsonConverter(typeof(StringEnumConverter))] + public WikiCompatibilityStatus? BetaCompatibilityStatus { get; set; } + + /// The human-readable summary of the compatibility status or workaround for the Stardew Valley beta (if any), without HTML formatitng. + public string BetaCompatibilitySummary { get; set; } + + /// The beta game or SMAPI version which broke this mod, if applicable. + public string BetaBrokeIn { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public ModExtendedMetadataModel() { } + + /// Construct an instance. + /// The mod metadata from the wiki (if available). + /// The mod metadata from SMAPI's internal DB (if available). + 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; + } + } + + /// Get update keys based on the metadata. + public IEnumerable 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 +{ + /// Specifies mods whose update-check info to fetch. + public class ModSearchModel + { + /********* + ** Accessors + *********/ + /// The mods for which to find data. + public ModSearchEntryModel[] Mods { get; set; } + + /// Whether to include extended metadata for each mod. + public bool IncludeExtendedMetadata { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an empty instance. + public ModSearchModel() + { + // needed for JSON deserialising + } + + /// Construct an instance. + /// The mods to search. + /// Whether to include extended metadata for each mod. + 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 +{ + /// Specifies the identifiers for a mod to match. + public class ModSearchEntryModel + { + /********* + ** Accessors + *********/ + /// The unique mod ID. + public string ID { get; set; } + + /// The namespaced mod update keys (if available). + public string[] UpdateKeys { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an empty instance. + public ModSearchEntryModel() + { + // needed for JSON deserialising + } + + /// Construct an instance. + /// The unique mod ID. + /// The namespaced mod update keys (if available). + 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 +{ + /// Provides methods for interacting with the SMAPI web API. + public class WebApiClient + { + /********* + ** Fields + *********/ + /// The base URL for the web API. + private readonly Uri BaseUrl; + + /// The API version number. + private readonly ISemanticVersion Version; + + /// The JSON serializer settings to use. + private readonly JsonSerializerSettings JsonSettings = new JsonHelper().JsonSettings; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The base URL for the web API. + /// The web API version. + public WebApiClient(string baseUrl, ISemanticVersion version) + { + this.BaseUrl = new Uri(baseUrl); + this.Version = version; + } + + /// Get metadata about a set of mods from the web API. + /// The mod keys for which to fetch the latest version. + /// Whether to include extended metadata for each mod. + public IDictionary GetModInfo(ModSearchEntryModel[] mods, bool includeExtendedMetadata = false) + { + return this.Post( + $"v{this.Version}/mods", + new ModSearchModel(mods, includeExtendedMetadata) + ).ToDictionary(p => p.ID); + } + + + /********* + ** Private methods + *********/ + /// Fetch the response from the backend API. + /// The body content type. + /// The expected response type. + /// The request URL, optionally excluding the base URL. + /// The body content to post. + private TResult Post(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(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 +{ + /// An HTTP client for fetching mod metadata from the wiki. + public class WikiClient : IDisposable + { + /********* + ** Fields + *********/ + /// The underlying HTTP client. + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The user agent for the wiki API. + /// The base URL for the wiki API. + public WikiClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php") + { + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// Fetch mods from the compatibility list. + public async Task FetchModsAsync() + { + // fetch HTML + ResponseModel response = await this.Client + .GetAsync("") + .WithArguments(new + { + action = "parse", + page = "Modding:Mod_compatibility", + format = "json" + }) + .As(); + string html = response.Parse.Text["*"]; + + // parse HTML + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // fetch game versions + string stableVersion = doc.DocumentNode.SelectSingleNode("div[@class='game-stable-version']")?.InnerText; + string betaVersion = doc.DocumentNode.SelectSingleNode("div[@class='game-beta-version']")?.InnerText; + if (betaVersion == stableVersion) + betaVersion = null; + + // 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 + }; + } + + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + public void Dispose() + { + this.Client?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// Parse valid mod compatibility entries. + /// The HTML compatibility entries. + private IEnumerable ParseEntries(IEnumerable 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(node, "data-status") ?? WikiCompatibilityStatus.Ok, + BrokeIn = this.GetAttribute(node, "data-broke-in"), + UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-unofficial-version"), + UnofficialUrl = this.GetAttribute(node, "data-unofficial-url"), + Summary = this.GetInnerHtml(node, "mod-summary")?.Trim() + }; + + // parse beta compatibility + WikiCompatibilityInfo betaCompatibility = null; + { + WikiCompatibilityStatus? betaStatus = this.GetAttributeAsEnum(node, "data-beta-status"); + if (betaStatus.HasValue) + { + betaCompatibility = new WikiCompatibilityInfo + { + Status = betaStatus.Value, + BrokeIn = this.GetAttribute(node, "data-beta-broke-in"), + UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-beta-unofficial-version"), + UnofficialUrl = this.GetAttribute(node, "data-beta-unofficial-url"), + Summary = this.GetInnerHtml(node, "mod-beta-summary") + }; + } + } + + // 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 + }; + } + } + + /// Get an attribute value. + /// The element whose attributes to read. + /// The attribute name. + private string GetAttribute(HtmlNode element, string name) + { + string value = element.GetAttributeValue(name, null); + if (string.IsNullOrWhiteSpace(value)) + return null; + + return WebUtility.HtmlDecode(value); + } + + /// Get an attribute value and parse it as a comma-delimited list of strings. + /// The element whose attributes to read. + /// The attribute name. + private string[] GetAttributeAsCsv(HtmlNode element, string name) + { + string raw = this.GetAttribute(element, name); + return !string.IsNullOrWhiteSpace(raw) + ? raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() + : new string[0]; + } + + /// Get an attribute value and parse it as an enum value. + /// The enum type. + /// The element whose attributes to read. + /// The attribute name. + private TEnum? GetAttributeAsEnum(HtmlNode element, string name) where TEnum : struct + { + string raw = this.GetAttribute(element, name); + if (raw == null) + return null; + if (!Enum.TryParse(raw, true, out TEnum value) && Enum.IsDefined(typeof(TEnum), value)) + throw new InvalidOperationException($"Unknown {typeof(TEnum).Name} value '{raw}' when parsing compatibility list."); + return value; + } + + /// Get an attribute value and parse it as a semantic version. + /// The element whose attributes to read. + /// The attribute name. + private ISemanticVersion GetAttributeAsSemanticVersion(HtmlNode element, string name) + { + string raw = this.GetAttribute(element, name); + return SemanticVersion.TryParse(raw, out ISemanticVersion version) + ? version + : null; + } + + /// Get an attribute value and parse it as a nullable int. + /// The element whose attributes to read. + /// The attribute name. + private int? GetAttributeAsNullableInt(HtmlNode element, string name) + { + string raw = this.GetAttribute(element, name); + if (raw != null && int.TryParse(raw, out int value)) + return value; + return null; + } + + /// Get the text of an element with the given class name. + /// The metadata container. + /// The field name. + private string GetInnerHtml(HtmlNode container, string className) + { + return container.Descendants().FirstOrDefault(p => p.HasClass(className))?.InnerHtml; + } + + /// The response model for the MediaWiki parse API. + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + private class ResponseModel + { + /// The parse API results. + public ResponseParseModel Parse { get; set; } + } + + /// The inner response model for the MediaWiki parse API. + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] + [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + private class ResponseParseModel + { + /// The parsed text. + public IDictionary 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 +{ + /// Compatibility info for a mod. + public class WikiCompatibilityInfo + { + /********* + ** Accessors + *********/ + /// The compatibility status. + public WikiCompatibilityStatus Status { get; set; } + + /// The human-readable summary of the compatibility status or workaround, without HTML formatting. + public string Summary { get; set; } + + /// The game or SMAPI version which broke this mod (if applicable). + public string BrokeIn { get; set; } + + /// The version of the latest unofficial update, if applicable. + public ISemanticVersion UnofficialVersion { get; set; } + + /// The URL to the latest unofficial update, if applicable. + 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 +{ + /// The compatibility status for a mod. + public enum WikiCompatibilityStatus + { + /// The mod is compatible. + Ok = 0, + + /// The mod is compatible if you use an optional official download. + Optional = 1, + + /// The mod is compatible if you use an unofficial update. + Unofficial = 2, + + /// The mod isn't compatible, but the player can fix it or there's a good alternative. + Workaround = 3, + + /// The mod isn't compatible. + Broken = 4, + + /// The mod is no longer maintained by the author, and an unofficial update or continuation is unlikely. + Abandoned = 5, + + /// The mod is no longer needed and should be removed. + 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 +{ + /// A mod entry in the wiki list. + public class WikiModEntry + { + /********* + ** Accessors + *********/ + /// The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to newest order. + public string[] ID { get; set; } + + /// The mod's display name. If the mod has multiple names, the first one is the most canonical name. + public string[] Name { get; set; } + + /// The mod's author name. If the author has multiple names, the first one is the most canonical name. + public string[] Author { get; set; } + + /// The mod ID on Nexus. + public int? NexusID { get; set; } + + /// The mod ID in the Chucklefish mod repo. + public int? ChucklefishID { get; set; } + + /// The mod ID in the ModDrop mod repo. + public int? ModDropID { get; set; } + + /// The GitHub repository in the form 'owner/repo'. + public string GitHubRepo { get; set; } + + /// The URL to a non-GitHub source repo. + public string CustomSourceUrl { get; set; } + + /// The custom mod page URL (if applicable). + public string CustomUrl { get; set; } + + /// The name of the mod which loads this content pack, if applicable. + public string ContentPackFor { get; set; } + + /// The mod's compatibility with the latest stable version of the game. + public WikiCompatibilityInfo Compatibility { get; set; } + + /// The mod's compatibility with the latest beta version of the game (if any). + public WikiCompatibilityInfo BetaCompatibility { get; set; } + + /// Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, should be used for beta versions of SMAPI instead of . + public bool HasBetaInfo => this.BetaCompatibility != null; + + /// The human-readable warnings for players about this mod. + public string[] Warnings { get; set; } + + /// The link anchor for the mod entry in the wiki compatibility list. + 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 +{ + /// Metadata from the wiki's mod compatibility list. + public class WikiModList + { + /********* + ** Accessors + *********/ + /// The stable game version. + public string StableVersion { get; set; } + + /// The beta game version (if any). + public string BetaVersion { get; set; } + + /// The mods on the wiki. + 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 +{ + /// The SMAPI predefined metadata. + internal class MetadataModel + { + /******** + ** Accessors + ********/ + /// Extra metadata about mods. + public IDictionary 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 +{ + /// A versioned mod metadata field. + public class ModDataField + { + /********* + ** Accessors + *********/ + /// The field key. + public ModDataFieldKey Key { get; } + + /// The field value. + public string Value { get; } + + /// Whether this field should only be applied if it's not already set. + public bool IsDefault { get; } + + /// The lowest version in the range, or null for all past versions. + public ISemanticVersion LowerVersion { get; } + + /// The highest version in the range, or null for all future versions. + public ISemanticVersion UpperVersion { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The field key. + /// The field value. + /// Whether this field should only be applied if it's not already set. + /// The lowest version in the range, or null for all past versions. + /// The highest version in the range, or null for all future versions. + 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; + } + + /// Get whether this data field applies for the given manifest. + /// The mod manifest. + 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 + *********/ + /// Get whether a manifest field has a meaningful value for the purposes of enforcing . + /// The mod manifest. + /// The field key matching . + 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 +{ + /// The valid field keys. + public enum ModDataFieldKey + { + /// A manifest update key. + UpdateKey, + + /// An alternative URL the player can check for an updated version. + AlternativeUrl, + + /// The mod's predefined compatibility status. + Status, + + /// A reason phrase for the , or null to use the default reason. + 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 +{ + /// The raw mod metadata from SMAPI's internal mod list. + internal class ModDataModel + { + /********* + ** Accessors + *********/ + /// The mod's current unique ID. + public string ID { get; set; } + + /// The former mod IDs (if any). + /// + /// 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 + /// | character. + /// + public string FormerIDs { get; set; } + + /// Maps local versions to a semantic version for update checks. + public IDictionary MapLocalVersions { get; set; } = new Dictionary(); + + /// Maps remote versions to a semantic version for update checks. + public IDictionary MapRemoteVersions { get; set; } = new Dictionary(); + + /// The mod warnings to suppress, even if they'd normally be shown. + public ModWarning SuppressWarnings { get; set; } + + /// This field stores properties that aren't mapped to another field before they're parsed into . + [JsonExtensionData] + public IDictionary ExtensionData { get; set; } + + /// The versioned field data. + /// + /// This maps field names to values. This should be accessed via . + /// Format notes: + /// - Each key consists of a field name prefixed with any combination of version range + /// and Default, separated by pipes (whitespace trimmed). For example, Name + /// will always override the name, Default | Name will only override a blank + /// name, and ~1.1 | Default | Name will override blank names up to version 1.1. + /// - The version format is min~max (where either side can be blank for unbounded), or + /// a single version number. + /// - The field name itself corresponds to a value. + /// + public IDictionary Fields { get; set; } = new Dictionary(); + + + /********* + ** Public methods + *********/ + /// Get a parsed representation of the . + public IEnumerable GetFields() + { + foreach (KeyValuePair 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); + } + } + + /// Get the former mod IDs. + public IEnumerable GetFormerIDs() + { + if (this.FormerIDs != null) + { + foreach (string id in this.FormerIDs.Split('|')) + yield return id.Trim(); + } + } + + + /********* + ** Private methods + *********/ + /// The method invoked after JSON deserialisation. + /// The deserialisation context. + [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 +{ + /// The parsed mod metadata from SMAPI's internal mod list. + public class ModDataRecord + { + /********* + ** Accessors + *********/ + /// The mod's default display name. + public string DisplayName { get; } + + /// The mod's current unique ID. + public string ID { get; } + + /// The former mod IDs (if any). + public string[] FormerIDs { get; } + + /// The mod warnings to suppress, even if they'd normally be shown. + public ModWarning SuppressWarnings { get; set; } + + /// Maps local versions to a semantic version for update checks. + public IDictionary MapLocalVersions { get; } + + /// Maps remote versions to a semantic version for update checks. + public IDictionary MapRemoteVersions { get; } + + /// The versioned field data. + public ModDataField[] Fields { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod's default display name. + /// The raw data model. + 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(model.MapLocalVersions, StringComparer.InvariantCultureIgnoreCase); + this.MapRemoteVersions = new Dictionary(model.MapRemoteVersions, StringComparer.InvariantCultureIgnoreCase); + this.Fields = model.GetFields().ToArray(); + } + + /// Get whether the mod has (or previously had) the given ID. + /// The mod ID. + 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; + } + + /// Get a semantic local version for update checks. + /// The remote version to normalise. + public ISemanticVersion GetLocalVersionForUpdateChecks(ISemanticVersion version) + { + return this.MapLocalVersions != null && this.MapLocalVersions.TryGetValue(version.ToString(), out string newVersion) + ? new SemanticVersion(newVersion) + : version; + } + + /// Get a semantic remote version for update checks. + /// The remote version to normalise. + 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; + } + + /// Get the possible mod IDs. + public IEnumerable GetIDs() + { + return this.FormerIDs + .Concat(new[] { this.ID }) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim()) + .Distinct(); + } + + /// Get the default update key for this mod, if any. + public string GetDefaultUpdateKey() + { + string updateKey = this.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value; + return !string.IsNullOrWhiteSpace(updateKey) + ? updateKey + : null; + } + + /// Get a parsed representation of the which match a given manifest. + /// The manifest to match. + 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 +{ + /// The versioned fields from a for a specific manifest. + public class ModDataRecordVersionedFields + { + /********* + ** Accessors + *********/ + /// The underlying data record. + public ModDataRecord DataRecord { get; set; } + + /// The default mod name to display when the name isn't available (e.g. during dependency checks). + public string DisplayName { get; set; } + + /// The update key to apply. + public string UpdateKey { get; set; } + + /// The alternative URL the player can check for an updated version. + public string AlternativeUrl { get; set; } + + /// The predefined compatibility status. + public ModStatus Status { get; set; } = ModStatus.None; + + /// A reason phrase for the , or null to use the default reason. + public string StatusReasonPhrase { get; set; } + + /// The upper version for which the applies (if any). + public ISemanticVersion StatusUpperVersion { get; set; } + + + /********* + ** Public methods + *********/ + /// Get a semantic local version for update checks. + /// The remote version to normalise. + public ISemanticVersion GetLocalVersionForUpdateChecks(ISemanticVersion version) + { + return this.DataRecord.GetLocalVersionForUpdateChecks(version); + } + + /// Get a semantic remote version for update checks. + /// The remote version to normalise. + 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 +{ + /// Handles access to SMAPI's internal mod metadata list. + public class ModDatabase + { + /********* + ** Fields + *********/ + /// The underlying mod data records indexed by default display name. + private readonly ModDataRecord[] Records; + + /// Get an update URL for an update key (if valid). + private readonly Func GetUpdateUrl; + + + /********* + ** Public methods + *********/ + /// Construct an empty instance. + public ModDatabase() + : this(new ModDataRecord[0], key => null) { } + + /// Construct an instance. + /// The underlying mod data records indexed by default display name. + /// Get an update URL for an update key (if valid). + public ModDatabase(IEnumerable records, Func getUpdateUrl) + { + this.Records = records.ToArray(); + this.GetUpdateUrl = getUpdateUrl; + } + + /// Get all mod data records. + public IEnumerable GetAll() + { + return this.Records; + } + + /// Get a mod data record. + /// The unique mod ID. + public ModDataRecord Get(string modID) + { + return !string.IsNullOrWhiteSpace(modID) + ? this.Records.FirstOrDefault(p => p.HasID(modID)) + : null; + } + + /// Get the mod page URL for a mod (if available). + /// The unique mod ID. + 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 +{ + /// Indicates how SMAPI should treat a mod. + public enum ModStatus + { + /// Don't override the status. + None, + + /// The mod is obsolete and shouldn't be used, regardless of version. + Obsolete, + + /// Assume the mod is not compatible, even if SMAPI doesn't detect any incompatible code. + AssumeBroken, + + /// Assume the mod is compatible, even if SMAPI detects incompatible code. + 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 +{ + /// Indicates a detected non-error mod issue. + [Flags] + public enum ModWarning + { + /// No issues detected. + None = 0, + + /// SMAPI detected incompatible code in the mod, but was configured to load it anyway. + BrokenCodeLoaded = 1, + + /// The mod affects the save serializer in a way that may make saves unloadable without the mod. + ChangesSaveSerialiser = 2, + + /// The mod patches the game in a way that may impact stability. + PatchesGame = 4, + + /// The mod uses the dynamic keyword which won't work on Linux/Mac. + UsesDynamic = 8, + + /// The mod references specialised 'unvalided update tick' events which may impact stability. + UsesUnvalidatedUpdateTick = 16, + + /// The mod has no update keys set. + NoUpdateKeys = 32, + + /// Uses .NET APIs for filesystem access. + AccessesFilesystem = 64, + + /// Uses .NET APIs for shell or process access. + 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 +{ + /// The info about a mod read from its folder. + public class ModFolder + { + /********* + ** Accessors + *********/ + /// A suggested display name for the mod folder. + public string DisplayName { get; } + + /// The folder containing the mod's manifest.json. + public DirectoryInfo Directory { get; } + + /// The mod manifest. + public Manifest Manifest { get; } + + /// The error which occurred parsing the manifest, if any. + public string ManifestParseError { get; } + + /// Whether the mod should be loaded by default. This is false if it was found within a folder whose name starts with a dot. + public bool ShouldBeLoaded { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The root folder containing mods. + /// The folder containing the mod's manifest.json. + /// The mod manifest. + /// The error which occurred parsing the manifest, if any. + /// Whether the mod should be loaded by default. This should be false if it was found within a folder whose name starts with a dot. + 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); + } + + /// Get the update keys for a mod. + /// The mod manifest. + public IEnumerable 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 +{ + /// Scans folders for mod data. + public class ModScanner + { + /********* + ** Fields + *********/ + /// The JSON helper with which to read manifests. + private readonly JsonHelper JsonHelper; + + /// A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod. + private readonly HashSet IgnoreFilesystemEntries = new HashSet(StringComparer.InvariantCultureIgnoreCase) + { + ".DS_Store", + "mcs", + "Thumbs.db" + }; + + /// 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. + private readonly HashSet PotentialXnbModExtensions = new HashSet(StringComparer.InvariantCultureIgnoreCase) + { + ".md", + ".png", + ".txt", + ".xnb" + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The JSON helper with which to read manifests. + public ModScanner(JsonHelper jsonHelper) + { + this.JsonHelper = jsonHelper; + } + + /// Extract information about all mods in the given folder. + /// The root folder containing mods. + public IEnumerable GetModFolders(string rootPath) + { + DirectoryInfo root = new DirectoryInfo(rootPath); + return this.GetModFolders(root, root); + } + + /// Extract information from a mod folder. + /// The root folder containing mods. + /// The folder to search for a mod. + 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(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 + *********/ + /// Recursively extract information about all mods in the given folder. + /// The root mod folder. + /// The folder to search for mods. + public IEnumerable 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); + } + + /// Find the manifest for a mod folder. + /// The folder to search. + 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; + } + } + + /// Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods). + /// The root mod folder. + /// The folder to search for mods. + 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(); + } + + /// Get whether a file or folder is relevant when deciding how to process a mod folder. + /// The file or folder. + private bool IsRelevant(FileSystemInfo entry) + { + return !this.IgnoreFilesystemEntries.Contains(entry.Name); + } + + /// Strip newlines from a string. + /// The input to strip. + 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 +{ + /// A mod repository which SMAPI can check for updates. + public enum ModRepositoryKey + { + /// An unknown or invalid mod repository. + Unknown, + + /// The Chucklefish mod repository. + Chucklefish, + + /// A GitHub project containing releases. + GitHub, + + /// The ModDrop mod repository. + ModDrop, + + /// The Nexus Mods mod repository. + 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 +{ + /// A namespaced mod ID which uniquely identifies a mod within a mod repository. + public class UpdateKey + { + /********* + ** Accessors + *********/ + /// The raw update key text. + public string RawText { get; } + + /// The mod repository containing the mod. + public ModRepositoryKey Repository { get; } + + /// The mod ID within the repository. + public string ID { get; } + + /// Whether the update key seems to be valid. + public bool LooksValid { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The raw update key text. + /// The mod repository containing the mod. + /// The mod ID within the repository. + 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); + } + + /// Parse a raw update key. + /// The raw update key to parse. + 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); + } + + /// Get a string that represents the current object. + public override string ToString() + { + return this.LooksValid + ? $"{this.Repository}:{this.ID}" + : this.RawText; + } + } +} -- cgit