diff options
Diffstat (limited to 'src/StardewModdingAPI.Toolkit')
19 files changed, 612 insertions, 241 deletions
diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs index 2aafe199..8a9c0a25 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs @@ -18,9 +18,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary> public ModEntryVersionModel Unofficial { get; set; } + /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see <see cref="HasBetaInfo"/>).</summary> + public ModEntryVersionModel UnofficialForBeta { get; set; } + /// <summary>Optional extended data which isn't needed for update checks.</summary> public ModExtendedMetadataModel Metadata { get; set; } + /// <summary>Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, <see cref="UnofficialForBeta"/> should be used for beta versions of SMAPI instead of <see cref="Unofficial"/>.</summary> + public bool HasBetaInfo { get; set; } + /// <summary>The errors that occurred while fetching update data.</summary> public string[] Errors { get; set; } = new string[0]; } diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs index 21376b36..247730d7 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs @@ -13,6 +13,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /********* ** Accessors *********/ + /**** + ** Mod info + ****/ /// <summary>The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates).</summary> public string[] ID { get; set; } = new string[0]; @@ -34,6 +37,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>The custom mod page URL (if applicable).</summary> public string CustomUrl { get; set; } + /**** + ** Stable compatibility + ****/ /// <summary>The compatibility status.</summary> [JsonConverter(typeof(StringEnumConverter))] public WikiCompatibilityStatus? CompatibilityStatus { get; set; } @@ -42,6 +48,17 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public string CompatibilitySummary { get; set; } + /**** + ** Beta compatibility + ****/ + /// <summary>The compatibility status for the Stardew Valley beta (if any).</summary> + [JsonConverter(typeof(StringEnumConverter))] + public WikiCompatibilityStatus? BetaCompatibilityStatus { get; set; } + + /// <summary>The human-readable summary of the compatibility status or workaround for the Stardew Valley beta (if any), without HTML formatitng.</summary> + public string BetaCompatibilitySummary { get; set; } + + /********* ** Public methods *********/ @@ -51,20 +68,24 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>Construct an instance.</summary> /// <param name="wiki">The mod metadata from the wiki (if available).</param> /// <param name="db">The mod metadata from SMAPI's internal DB (if available).</param> - public ModExtendedMetadataModel(WikiCompatibilityEntry wiki, ModDataRecord db) + public ModExtendedMetadataModel(WikiModEntry wiki, ModDataRecord db) { // wiki data if (wiki != null) { this.ID = wiki.ID; - this.Name = wiki.Name; + this.Name = wiki.Name.FirstOrDefault(); this.NexusID = wiki.NexusID; this.ChucklefishID = wiki.ChucklefishID; this.GitHubRepo = wiki.GitHubRepo; this.CustomSourceUrl = wiki.CustomSourceUrl; this.CustomUrl = wiki.CustomUrl; - this.CompatibilityStatus = wiki.Status; - this.CompatibilitySummary = wiki.Summary; + + this.CompatibilityStatus = wiki.Compatibility.Status; + this.CompatibilitySummary = wiki.Compatibility.Summary; + + this.BetaCompatibilityStatus = wiki.BetaCompatibility?.Status; + this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary; } // internal DB data diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs new file mode 100644 index 00000000..7197bf2c --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using HtmlAgilityPack; +using Pathoschild.Http.Client; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>An HTTP client for fetching mod metadata from the wiki.</summary> + public class WikiClient : IDisposable + { + /********* + ** Properties + *********/ + /// <summary>The underlying HTTP client.</summary> + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="userAgent">The user agent for the wiki API.</param> + /// <param name="baseUrl">The base URL for the wiki API.</param> + public WikiClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php") + { + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// <summary>Fetch mods from the compatibility list.</summary> + public async Task<WikiModList> FetchModsAsync() + { + // fetch HTML + ResponseModel response = await this.Client + .GetAsync("") + .WithArguments(new + { + action = "parse", + page = "Modding:SMAPI_compatibility", + format = "json" + }) + .As<ResponseModel>(); + string html = response.Parse.Text["*"]; + + // parse HTML + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // fetch game versions + string stableVersion = doc.DocumentNode.SelectSingleNode("div[@class='game-stable-version']")?.InnerText; + string betaVersion = doc.DocumentNode.SelectSingleNode("div[@class='game-beta-version']")?.InnerText; + + // find mod entries + HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("table[@id='mod-list']//tr[@class='mod']"); + if (modNodes == null) + throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); + + // parse + WikiModEntry[] mods = this.ParseEntries(modNodes).ToArray(); + return new WikiModList + { + StableVersion = stableVersion, + BetaVersion = betaVersion, + Mods = mods + }; + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.Client?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Parse valid mod compatibility entries.</summary> + /// <param name="nodes">The HTML compatibility entries.</param> + private IEnumerable<WikiModEntry> ParseEntries(IEnumerable<HtmlNode> nodes) + { + foreach (HtmlNode node in nodes) + { + // extract fields + string[] names = this.GetAttributeAsCsv(node, "data-name"); + string[] authors = this.GetAttributeAsCsv(node, "data-author"); + string[] ids = this.GetAttributeAsCsv(node, "data-id"); + string[] warnings = this.GetAttributeAsCsv(node, "data-warnings"); + int? nexusID = this.GetAttributeAsNullableInt(node, "data-nexus-id"); + int? chucklefishID = this.GetAttributeAsNullableInt(node, "data-cf-id"); + string githubRepo = this.GetAttribute(node, "data-github"); + string customSourceUrl = this.GetAttribute(node, "data-custom-source"); + string customUrl = this.GetAttribute(node, "data-url"); + string anchor = this.GetAttribute(node, "id"); + + // parse stable compatibility + WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo + { + Status = this.GetAttributeAsStatus(node, "data-status") ?? WikiCompatibilityStatus.Ok, + BrokeIn = this.GetAttribute(node, "data-broke-in"), + UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-unofficial-version"), + UnofficialUrl = this.GetAttribute(node, "data-unofficial-url"), + Summary = this.GetInnerHtml(node, "mod-summary")?.Trim() + }; + + // parse beta compatibility + WikiCompatibilityInfo betaCompatibility = null; + { + WikiCompatibilityStatus? betaStatus = this.GetAttributeAsStatus(node, "data-beta-status"); + if (betaStatus.HasValue) + { + betaCompatibility = new WikiCompatibilityInfo + { + Status = betaStatus.Value, + BrokeIn = this.GetAttribute(node, "data-beta-broke-in"), + UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-beta-unofficial-version"), + UnofficialUrl = this.GetAttribute(node, "data-beta-unofficial-url"), + Summary = this.GetInnerHtml(node, "mod-beta-summary") + }; + } + } + + // yield model + yield return new WikiModEntry + { + ID = ids, + Name = names, + Author = authors, + NexusID = nexusID, + ChucklefishID = chucklefishID, + GitHubRepo = githubRepo, + CustomSourceUrl = customSourceUrl, + CustomUrl = customUrl, + Compatibility = compatibility, + BetaCompatibility = betaCompatibility, + Warnings = warnings, + Anchor = anchor + }; + } + } + + /// <summary>Get an attribute value.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + private string GetAttribute(HtmlNode element, string name) + { + string value = element.GetAttributeValue(name, null); + if (string.IsNullOrWhiteSpace(value)) + return null; + + return WebUtility.HtmlDecode(value); + } + + /// <summary>Get an attribute value and parse it as a comma-delimited list of strings.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + private string[] GetAttributeAsCsv(HtmlNode element, string name) + { + string raw = this.GetAttribute(element, name); + return !string.IsNullOrWhiteSpace(raw) + ? raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() + : new string[0]; + } + + /// <summary>Get an attribute value and parse it as a compatibility status.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + private WikiCompatibilityStatus? GetAttributeAsStatus(HtmlNode element, string name) + { + string raw = this.GetAttribute(element, name); + if (raw == null) + return null; + if (!Enum.TryParse(raw, true, out WikiCompatibilityStatus status)) + throw new InvalidOperationException($"Unknown status '{raw}' when parsing compatibility list."); + return status; + } + + /// <summary>Get an attribute value and parse it as a semantic version.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + private ISemanticVersion GetAttributeAsSemanticVersion(HtmlNode element, string name) + { + string raw = this.GetAttribute(element, name); + return SemanticVersion.TryParse(raw, out ISemanticVersion version) + ? version + : null; + } + + /// <summary>Get an attribute value and parse it as a nullable int.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + private int? GetAttributeAsNullableInt(HtmlNode element, string name) + { + string raw = this.GetAttribute(element, name); + if (raw != null && int.TryParse(raw, out int value)) + return value; + return null; + } + + /// <summary>Get the text of an element with the given class name.</summary> + /// <param name="container">The metadata container.</param> + /// <param name="className">The field name.</param> + private string GetInnerHtml(HtmlNode container, string className) + { + return container.Descendants().FirstOrDefault(p => p.HasClass(className))?.InnerHtml; + } + + /// <summary>The response model for the MediaWiki parse API.</summary> + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + private class ResponseModel + { + /// <summary>The parse API results.</summary> + public ResponseParseModel Parse { get; set; } + } + + /// <summary>The inner response model for the MediaWiki parse API.</summary> + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] + [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + private class ResponseParseModel + { + /// <summary>The parsed text.</summary> + public IDictionary<string, string> Text { get; set; } + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs deleted file mode 100644 index d0da42df..00000000 --- a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading.Tasks; -using HtmlAgilityPack; -using Pathoschild.Http.Client; - -namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki -{ - /// <summary>An HTTP client for fetching mod metadata from the wiki compatibility list.</summary> - public class WikiCompatibilityClient : IDisposable - { - /********* - ** Properties - *********/ - /// <summary>The underlying HTTP client.</summary> - private readonly IClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="userAgent">The user agent for the wiki API.</param> - /// <param name="baseUrl">The base URL for the wiki API.</param> - public WikiCompatibilityClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php") - { - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - } - - /// <summary>Fetch mod compatibility entries.</summary> - public async Task<WikiCompatibilityEntry[]> FetchAsync() - { - // fetch HTML - ResponseModel response = await this.Client - .GetAsync("") - .WithArguments(new - { - action = "parse", - page = "Modding:SMAPI_compatibility", - format = "json" - }) - .As<ResponseModel>(); - string html = response.Parse.Text["*"]; - - // parse HTML - var doc = new HtmlDocument(); - doc.LoadHtml(html); - - // find mod entries - HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("table[@id='mod-list']//tr[@class='mod']"); - if (modNodes == null) - throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); - - // parse - return this.ParseEntries(modNodes).ToArray(); - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public void Dispose() - { - this.Client?.Dispose(); - } - - - /********* - ** Private methods - *********/ - /// <summary>Parse valid mod compatibility entries.</summary> - /// <param name="nodes">The HTML compatibility entries.</param> - private IEnumerable<WikiCompatibilityEntry> ParseEntries(IEnumerable<HtmlNode> nodes) - { - foreach (HtmlNode node in nodes) - { - // parse status - WikiCompatibilityStatus status; - { - string rawStatus = node.GetAttributeValue("data-status", null); - if (rawStatus == null) - continue; // not a mod node? - if (!Enum.TryParse(rawStatus, true, out status)) - throw new InvalidOperationException($"Unknown status '{rawStatus}' when parsing compatibility list."); - } - - // parse unofficial version - ISemanticVersion unofficialVersion = null; - { - string rawUnofficialVersion = node.GetAttributeValue("data-unofficial-version", null); - SemanticVersion.TryParse(rawUnofficialVersion, out unofficialVersion); - } - - // parse other fields - string name = node.Descendants("td").FirstOrDefault()?.InnerText?.Trim(); - string summary = node.Descendants("td").FirstOrDefault(p => p.GetAttributeValue("class", null) == "summary")?.InnerText.Trim(); - string[] ids = this.GetAttribute(node, "data-id")?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() ?? new string[0]; - int? nexusID = this.GetNullableIntAttribute(node, "data-nexus-id"); - int? chucklefishID = this.GetNullableIntAttribute(node, "data-chucklefish-id"); - string githubRepo = this.GetAttribute(node, "data-github"); - string customSourceUrl = this.GetAttribute(node, "data-custom-source"); - string customUrl = this.GetAttribute(node, "data-custom-url"); - - // yield model - yield return new WikiCompatibilityEntry - { - ID = ids, - Name = name, - Status = status, - NexusID = nexusID, - ChucklefishID = chucklefishID, - GitHubRepo = githubRepo, - CustomSourceUrl = customSourceUrl, - CustomUrl = customUrl, - UnofficialVersion = unofficialVersion, - Summary = summary - }; - } - } - - /// <summary>Get a nullable integer attribute value.</summary> - /// <param name="node">The HTML node.</param> - /// <param name="attributeName">The attribute name.</param> - private int? GetNullableIntAttribute(HtmlNode node, string attributeName) - { - string raw = this.GetAttribute(node, attributeName); - if (raw != null && int.TryParse(raw, out int value)) - return value; - return null; - } - - /// <summary>Get a strings attribute value.</summary> - /// <param name="node">The HTML node.</param> - /// <param name="attributeName">The attribute name.</param> - private string GetAttribute(HtmlNode node, string attributeName) - { - string raw = node.GetAttributeValue(attributeName, null); - if (raw != null) - raw = HtmlEntity.DeEntitize(raw); - return raw; - } - - /// <summary>The response model for the MediaWiki parse API.</summary> - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] - [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] - private class ResponseModel - { - /// <summary>The parse API results.</summary> - public ResponseParseModel Parse { get; set; } - } - - /// <summary>The inner response model for the MediaWiki parse API.</summary> - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] - [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")] - [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] - private class ResponseParseModel - { - /// <summary>The parsed text.</summary> - public IDictionary<string, string> Text { get; set; } - } - } -} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs deleted file mode 100644 index 8bc66e20..00000000 --- a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki -{ - /// <summary>An entry in the mod compatibility list.</summary> - public class WikiCompatibilityEntry - { - /// <summary>The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates).</summary> - public string[] ID { get; set; } - - /// <summary>The mod's display name.</summary> - public string Name { get; set; } - - /// <summary>The mod ID on Nexus.</summary> - public int? NexusID { get; set; } - - /// <summary>The mod ID in the Chucklefish mod repo.</summary> - public int? ChucklefishID { get; set; } - - /// <summary>The GitHub repository in the form 'owner/repo'.</summary> - public string GitHubRepo { get; set; } - - /// <summary>The URL to a non-GitHub source repo.</summary> - public string CustomSourceUrl { get; set; } - - /// <summary>The custom mod page URL (if applicable).</summary> - public string CustomUrl { get; set; } - - /// <summary>The version of the latest unofficial update, if applicable.</summary> - public ISemanticVersion UnofficialVersion { get; set; } - - /// <summary>The compatibility status.</summary> - public WikiCompatibilityStatus Status { get; set; } - - /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatitng.</summary> - public string Summary { get; set; } - } -} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs new file mode 100644 index 00000000..204acd2b --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>Compatibility info for a mod.</summary> + public class WikiCompatibilityInfo + { + /********* + ** Accessors + *********/ + /// <summary>The compatibility status.</summary> + public WikiCompatibilityStatus Status { get; set; } + + /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary> + public string Summary { get; set; } + + /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary> + public string BrokeIn { get; set; } + + /// <summary>The version of the latest unofficial update, if applicable.</summary> + public ISemanticVersion UnofficialVersion { get; set; } + + /// <summary>The URL to the latest unofficial update, if applicable.</summary> + public string UnofficialUrl { get; set; } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs new file mode 100644 index 00000000..ce8d6c5f --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -0,0 +1,48 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>A mod entry in the wiki list.</summary> + public class WikiModEntry + { + /********* + ** Accessors + *********/ + /// <summary>The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to newest order.</summary> + public string[] ID { get; set; } + + /// <summary>The mod's display name. If the mod has multiple names, the first one is the most canonical name.</summary> + public string[] Name { get; set; } + + /// <summary>The mod's author name. If the author has multiple names, the first one is the most canonical name.</summary> + public string[] Author { get; set; } + + /// <summary>The mod ID on Nexus.</summary> + public int? NexusID { get; set; } + + /// <summary>The mod ID in the Chucklefish mod repo.</summary> + public int? ChucklefishID { get; set; } + + /// <summary>The GitHub repository in the form 'owner/repo'.</summary> + public string GitHubRepo { get; set; } + + /// <summary>The URL to a non-GitHub source repo.</summary> + public string CustomSourceUrl { get; set; } + + /// <summary>The custom mod page URL (if applicable).</summary> + public string CustomUrl { get; set; } + + /// <summary>The mod's compatibility with the latest stable version of the game.</summary> + public WikiCompatibilityInfo Compatibility { get; set; } + + /// <summary>The mod's compatibility with the latest beta version of the game (if any).</summary> + public WikiCompatibilityInfo BetaCompatibility { get; set; } + + /// <summary>Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, <see cref="BetaCompatibility"/> should be used for beta versions of SMAPI instead of <see cref="Compatibility"/>.</summary> + public bool HasBetaInfo => this.BetaCompatibility != null; + + /// <summary>The human-readable warnings for players about this mod.</summary> + public string[] Warnings { get; set; } + + /// <summary>The link anchor for the mod entry in the wiki compatibility list.</summary> + public string Anchor { get; set; } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs new file mode 100644 index 00000000..0d614f28 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>Metadata from the wiki's mod compatibility list.</summary> + public class WikiModList + { + /********* + ** Accessors + *********/ + /// <summary>The stable game version.</summary> + public string StableVersion { get; set; } + + /// <summary>The beta game version (if any).</summary> + public string BetaVersion { get; set; } + + /// <summary>The mods on the wiki.</summary> + public WikiModEntry[] Mods { get; set; } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs index 82ac8837..3949f7dc 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs @@ -96,6 +96,15 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData .Distinct(); } + /// <summary>Get the default update key for this mod, if any.</summary> + public string GetDefaultUpdateKey() + { + string updateKey = this.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value; + return !string.IsNullOrWhiteSpace(updateKey) + ? updateKey + : null; + } + /// <summary>Get a parsed representation of the <see cref="ModDataRecord.Fields"/> which match a given manifest.</summary> /// <param name="manifest">The manifest to match.</param> public ModDataRecordVersionedFields GetVersionedFields(IManifest manifest) diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs index 4aaa3f83..bb467b36 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Toolkit.Framework.ModScanning { @@ -11,11 +12,11 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /********* ** Accessors *********/ - /// <summary>The Mods subfolder containing this mod.</summary> - public DirectoryInfo SearchDirectory { get; } + /// <summary>A suggested display name for the mod folder.</summary> + public string DisplayName { get; } - /// <summary>The folder containing manifest.json.</summary> - public DirectoryInfo ActualDirectory { get; } + /// <summary>The folder containing the mod's manifest.json.</summary> + public DirectoryInfo Directory { get; } /// <summary>The mod manifest.</summary> public Manifest Manifest { get; } @@ -23,21 +24,31 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>The error which occurred parsing the manifest, if any.</summary> public string ManifestParseError { get; } + /// <summary>Whether the mod should be loaded by default. This is <c>false</c> if it was found within a folder whose name starts with a dot.</summary> + public bool ShouldBeLoaded { get; } + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="searchDirectory">The Mods subfolder containing this mod.</param> - /// <param name="actualDirectory">The folder containing manifest.json.</param> + /// <param name="root">The root folder containing mods.</param> + /// <param name="directory">The folder containing the mod's manifest.json.</param> /// <param name="manifest">The mod manifest.</param> /// <param name="manifestParseError">The error which occurred parsing the manifest, if any.</param> - public ModFolder(DirectoryInfo searchDirectory, DirectoryInfo actualDirectory, Manifest manifest, string manifestParseError = null) + /// <param name="shouldBeLoaded">Whether the mod should be loaded by default. This should be <c>false</c> if it was found within a folder whose name starts with a dot.</param> + public ModFolder(DirectoryInfo root, DirectoryInfo directory, Manifest manifest, string manifestParseError = null, bool shouldBeLoaded = true) { - this.SearchDirectory = searchDirectory; - this.ActualDirectory = actualDirectory; + // save info + this.Directory = directory; this.Manifest = manifest; this.ManifestParseError = manifestParseError; + this.ShouldBeLoaded = shouldBeLoaded; + + // set display name + this.DisplayName = manifest?.Name; + if (string.IsNullOrWhiteSpace(this.DisplayName)) + this.DisplayName = PathUtilities.GetRelativePath(root.FullName, directory.FullName); } /// <summary>Get the update keys for a mod.</summary> diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs index f1cce4a4..106c294f 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -16,6 +16,23 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>The JSON helper with which to read manifests.</summary> private readonly JsonHelper JsonHelper; + /// <summary>A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod.</summary> + private readonly HashSet<string> IgnoreFilesystemEntries = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) + { + ".DS_Store", + "mcs", + "Thumbs.db" + }; + + /// <summary>The extensions for files which an XNB mod may contain. If a mod contains *only* these file extensions, it should be considered an XNB mod.</summary> + private readonly HashSet<string> PotentialXnbModExtensions = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) + { + ".md", + ".png", + ".txt", + ".xnb" + }; + /********* ** Public methods @@ -31,19 +48,28 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <param name="rootPath">The root folder containing mods.</param> public IEnumerable<ModFolder> GetModFolders(string rootPath) { - foreach (DirectoryInfo folder in new DirectoryInfo(rootPath).EnumerateDirectories()) - yield return this.ReadFolder(rootPath, folder); + DirectoryInfo root = new DirectoryInfo(rootPath); + return this.GetModFolders(root, root); } /// <summary>Extract information from a mod folder.</summary> - /// <param name="rootPath">The root folder containing mods.</param> + /// <param name="root">The root folder containing mods.</param> /// <param name="searchFolder">The folder to search for a mod.</param> - public ModFolder ReadFolder(string rootPath, DirectoryInfo searchFolder) + public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder) { // find manifest.json FileInfo manifestFile = this.FindManifest(searchFolder); + + // set appropriate invalid-mod error if (manifestFile == null) - return new ModFolder(searchFolder, null, null, "it doesn't have a manifest."); + { + FileInfo[] files = searchFolder.GetFiles("*", SearchOption.AllDirectories).Where(this.IsRelevant).ToArray(); + if (!files.Any()) + return new ModFolder(root, searchFolder, null, "it's an empty folder."); + if (files.All(file => this.PotentialXnbModExtensions.Contains(file.Extension))) + return new ModFolder(root, searchFolder, null, "it's an older XNB mod which replaces game files (not run through SMAPI)."); + return new ModFolder(root, searchFolder, null, "it contains files, but none of them are manifest.json."); + } // read mod info Manifest manifest = null; @@ -51,7 +77,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning { try { - if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest)) + if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest) || manifest == null) manifestError = "its manifest is invalid."; } catch (SParseException ex) @@ -64,13 +90,37 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning } } - return new ModFolder(searchFolder, manifestFile.Directory, manifest, manifestError); + return new ModFolder(root, manifestFile.Directory, manifest, manifestError); } /********* ** Private methods *********/ + /// <summary>Recursively extract information about all mods in the given folder.</summary> + /// <param name="root">The root mod folder.</param> + /// <param name="folder">The folder to search for mods.</param> + public IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder) + { + // skip + if (folder.FullName != root.FullName && folder.Name.StartsWith(".")) + yield return new ModFolder(root, folder, null, "ignored folder because its name starts with a dot.", shouldBeLoaded: false); + + // recurse into subfolders + else if (this.IsModSearchFolder(root, folder)) + { + foreach (DirectoryInfo subfolder in folder.EnumerateDirectories()) + { + foreach (ModFolder match in this.GetModFolders(root, subfolder)) + yield return match; + } + } + + // treat as mod folder + else + yield return this.ReadFolder(root, folder); + } + /// <summary>Find the manifest for a mod folder.</summary> /// <param name="folder">The folder to search.</param> private FileInfo FindManifest(DirectoryInfo folder) @@ -94,5 +144,25 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning return null; } } + + /// <summary>Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods).</summary> + /// <param name="root">The root mod folder.</param> + /// <param name="folder">The folder to search for mods.</param> + private bool IsModSearchFolder(DirectoryInfo root, DirectoryInfo folder) + { + if (root.FullName == folder.FullName) + return true; + + DirectoryInfo[] subfolders = folder.GetDirectories().Where(this.IsRelevant).ToArray(); + FileInfo[] files = folder.GetFiles().Where(this.IsRelevant).ToArray(); + return subfolders.Any() && !files.Any(); + } + + /// <summary>Get whether a file or folder is relevant when deciding how to process a mod folder.</summary> + /// <param name="entry">The file or folder.</param> + private bool IsRelevant(FileSystemInfo entry) + { + return !this.IgnoreFilesystemEntries.Contains(entry.Name); + } } } diff --git a/src/StardewModdingAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs b/src/StardewModdingAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs new file mode 100644 index 00000000..7ca32f04 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Toolkit.Framework.UpdateData +{ + /// <summary>A mod repository which SMAPI can check for updates.</summary> + public enum ModRepositoryKey + { + /// <summary>An unknown or invalid mod repository.</summary> + Unknown, + + /// <summary>The Chucklefish mod repository.</summary> + Chucklefish, + + /// <summary>A GitHub project containing releases.</summary> + GitHub, + + /// <summary>The Nexus Mods mod repository.</summary> + Nexus + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/StardewModdingAPI.Toolkit/Framework/UpdateData/UpdateKey.cs new file mode 100644 index 00000000..865ebcf7 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -0,0 +1,73 @@ +using System; + +namespace StardewModdingAPI.Toolkit.Framework.UpdateData +{ + /// <summary>A namespaced mod ID which uniquely identifies a mod within a mod repository.</summary> + public class UpdateKey + { + /********* + ** Accessors + *********/ + /// <summary>The raw update key text.</summary> + public string RawText { get; } + + /// <summary>The mod repository containing the mod.</summary> + public ModRepositoryKey Repository { get; } + + /// <summary>The mod ID within the repository.</summary> + public string ID { get; } + + /// <summary>Whether the update key seems to be valid.</summary> + public bool LooksValid { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="rawText">The raw update key text.</param> + /// <param name="repository">The mod repository containing the mod.</param> + /// <param name="id">The mod ID within the repository.</param> + public UpdateKey(string rawText, ModRepositoryKey repository, string id) + { + this.RawText = rawText; + this.Repository = repository; + this.ID = id; + this.LooksValid = + repository != ModRepositoryKey.Unknown + && !string.IsNullOrWhiteSpace(id); + } + + /// <summary>Parse a raw update key.</summary> + /// <param name="raw">The raw update key to parse.</param> + public static UpdateKey Parse(string raw) + { + // split parts + string[] parts = raw?.Split(':'); + if (parts == null || parts.Length != 2) + return new UpdateKey(raw, ModRepositoryKey.Unknown, null); + + // extract parts + string repositoryKey = parts[0].Trim(); + string id = parts[1].Trim(); + if (string.IsNullOrWhiteSpace(id)) + id = null; + + // parse + if (!Enum.TryParse(repositoryKey, true, out ModRepositoryKey repository)) + return new UpdateKey(raw, ModRepositoryKey.Unknown, id); + if (id == null) + return new UpdateKey(raw, repository, null); + + return new UpdateKey(raw, repository, id); + } + + /// <summary>Get a string that represents the current object.</summary> + public override string ToString() + { + return this.LooksValid + ? $"{this.Repository}:{this.ID}" + : this.RawText; + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/ModToolkit.cs b/src/StardewModdingAPI.Toolkit/ModToolkit.cs index 8c78b2f3..c55f6c70 100644 --- a/src/StardewModdingAPI.Toolkit/ModToolkit.cs +++ b/src/StardewModdingAPI.Toolkit/ModToolkit.cs @@ -47,10 +47,10 @@ namespace StardewModdingAPI.Toolkit } /// <summary>Extract mod metadata from the wiki compatibility list.</summary> - public async Task<WikiCompatibilityEntry[]> GetWikiCompatibilityListAsync() + public async Task<WikiModList> GetWikiCompatibilityListAsync() { - var client = new WikiCompatibilityClient(this.UserAgent); - return await client.FetchAsync(); + var client = new WikiClient(this.UserAgent); + return await client.FetchModsAsync(); } /// <summary>Get SMAPI's internal mod database.</summary> diff --git a/src/StardewModdingAPI.Toolkit/SemanticVersion.cs b/src/StardewModdingAPI.Toolkit/SemanticVersion.cs index 156d58ce..a7990d13 100644 --- a/src/StardewModdingAPI.Toolkit/SemanticVersion.cs +++ b/src/StardewModdingAPI.Toolkit/SemanticVersion.cs @@ -4,7 +4,13 @@ using System.Text.RegularExpressions; namespace StardewModdingAPI.Toolkit { /// <summary>A semantic version with an optional release tag.</summary> - /// <remarks>The implementation is defined by Semantic Version 2.0 (http://semver.org/).</remarks> + /// <remarks> + /// The implementation is defined by Semantic Version 2.0 (https://semver.org/), with a few deviations: + /// - short-form "x.y" versions are supported (equivalent to "x.y.0"); + /// - hyphens are synonymous with dots in prerelease tags (like "-unofficial.3-pathoschild"); + /// - +build suffixes are not supported; + /// - and "-unofficial" in prerelease tags is always lower-precedence (e.g. "1.0-beta" is newer than "1.0-unofficial"). + /// </remarks> public class SemanticVersion : ISemanticVersion { /********* @@ -17,13 +23,7 @@ namespace StardewModdingAPI.Toolkit internal const string UnboundedVersionPattern = @"(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>" + SemanticVersion.TagPattern + "))?"; /// <summary>A regular expression matching a semantic version string.</summary> - /// <remarks> - /// This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>, - /// with three important deviations intended to support Stardew Valley mod conventions: - /// - allows short-form "x.y" versions; - /// - allows hyphens in prerelease tags as synonyms for dots (like "-unofficial-update.3"); - /// - doesn't allow '+build' suffixes. - /// </remarks> + /// <remarks>This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>, with deviations to support the Stardew Valley mod conventions (see remarks on <see cref="SemanticVersion"/>).</remarks> internal static readonly Regex Regex = new Regex($@"^{SemanticVersion.UnboundedVersionPattern}$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture); @@ -40,7 +40,14 @@ namespace StardewModdingAPI.Toolkit public int PatchVersion { get; } /// <summary>An optional prerelease tag.</summary> - public string Build { get; } + [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")] + public string Build => this.PrereleaseTag; + + /// <summary>An optional prerelease tag.</summary> + public string PrereleaseTag { get; } + + /// <summary>Whether the version was parsed from the legacy object format.</summary> + public bool IsLegacyFormat { get; } /********* @@ -51,12 +58,14 @@ namespace StardewModdingAPI.Toolkit /// <param name="minor">The minor version incremented for backwards-compatible changes.</param> /// <param name="patch">The patch version for backwards-compatible fixes.</param> /// <param name="tag">An optional prerelease tag.</param> - public SemanticVersion(int major, int minor, int patch, string tag = null) + /// <param name="isLegacyFormat">Whether the version was parsed from the legacy object format.</param> + public SemanticVersion(int major, int minor, int patch, string tag = null, bool isLegacyFormat = false) { this.MajorVersion = major; this.MinorVersion = minor; this.PatchVersion = patch; - this.Build = this.GetNormalisedTag(tag); + this.PrereleaseTag = this.GetNormalisedTag(tag); + this.IsLegacyFormat = isLegacyFormat; this.AssertValid(); } @@ -93,7 +102,7 @@ namespace StardewModdingAPI.Toolkit this.MajorVersion = int.Parse(match.Groups["major"].Value); this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0; this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0; - this.Build = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null; + this.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null; this.AssertValid(); } @@ -255,6 +264,12 @@ namespace StardewModdingAPI.Toolkit // compare if different if (curParts[i] != otherParts[i]) { + // unofficial is always lower-precedence + if (otherParts[i].Equals("unofficial", StringComparison.InvariantCultureIgnoreCase)) + return curNewer; + if (curParts[i].Equals("unofficial", StringComparison.InvariantCultureIgnoreCase)) + return curOlder; + // compare numerically if possible { if (int.TryParse(curParts[i], out int curNum) && int.TryParse(otherParts[i], out int otherNum)) diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs index 9b2f5e7d..e0e185c9 100644 --- a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs +++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs @@ -70,7 +70,7 @@ namespace StardewModdingAPI.Toolkit.Serialisation.Converters if (build == "0") build = null; // '0' from incorrect examples in old SMAPI documentation - return new SemanticVersion(major, minor, patch, build); + return new SemanticVersion(major, minor, patch, build, isLegacyFormat: true); } /// <summary>Read a JSON string.</summary> @@ -82,7 +82,7 @@ namespace StardewModdingAPI.Toolkit.Serialisation.Converters return null; if (!SemanticVersion.TryParse(str, out ISemanticVersion version)) throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta (path: {path})."); - return (SemanticVersion)version; + return version; } } } diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs index cc8eeb73..cf2ce0d1 100644 --- a/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs +++ b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs @@ -95,18 +95,14 @@ namespace StardewModdingAPI.Toolkit.Serialisation Directory.CreateDirectory(dir); // write file - string json = JsonConvert.SerializeObject(model, this.JsonSettings); + string json = this.Serialise(model); File.WriteAllText(fullPath, json); } - - /********* - ** Private methods - *********/ /// <summary>Deserialize JSON text if possible.</summary> /// <typeparam name="TModel">The model type.</typeparam> /// <param name="json">The raw JSON text.</param> - private TModel Deserialise<TModel>(string json) + public TModel Deserialise<TModel>(string json) { try { @@ -127,5 +123,14 @@ namespace StardewModdingAPI.Toolkit.Serialisation throw; } } + + /// <summary>Serialize a model to JSON text.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="model">The model to serialise.</param> + /// <param name="formatting">The formatting to apply.</param> + public string Serialise<TModel>(TModel model, Formatting formatting = Formatting.Indented) + { + return JsonConvert.SerializeObject(model, formatting, this.JsonSettings); + } } } diff --git a/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj b/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj index 21c130b3..3fa28d19 100644 --- a/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj +++ b/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj @@ -12,7 +12,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="HtmlAgilityPack" Version="1.8.4" /> + <PackageReference Include="HtmlAgilityPack" Version="1.8.9" /> <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.2.0" /> </ItemGroup> diff --git a/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs b/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs index 2e74e7d9..79748c25 100644 --- a/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs +++ b/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.Contracts; using System.IO; using System.Linq; +using System.Text.RegularExpressions; namespace StardewModdingAPI.Toolkit.Utilities { @@ -61,5 +62,24 @@ namespace StardewModdingAPI.Toolkit.Utilities relative = "./"; return relative; } + + /// <summary>Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain <c>../</c>).</summary> + /// <param name="path">The path to check.</param> + public static bool IsSafeRelativePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return true; + + return + !Path.IsPathRooted(path) + && PathUtilities.GetSegments(path).All(segment => segment.Trim() != ".."); + } + + /// <summary>Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc).</summary> + /// <param name="str">The string to check.</param> + public static bool IsSlug(string str) + { + return !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); + } } } |