diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-05-23 21:55:11 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2020-05-23 21:55:11 -0400 |
commit | 786077340f2cea37d82455fc413535ae82a912ee (patch) | |
tree | 588d6755b1001bd7eb218dcf9b332feb933e180b /src/SMAPI.Web/Framework | |
parent | d7add894419543667e60569bfeb439e8e797a4d1 (diff) | |
download | SMAPI-786077340f2cea37d82455fc413535ae82a912ee.tar.gz SMAPI-786077340f2cea37d82455fc413535ae82a912ee.tar.bz2 SMAPI-786077340f2cea37d82455fc413535ae82a912ee.zip |
refactor update check API
This simplifies the logic for individual clients, centralises common logic, and prepares for upcoming features.
Diffstat (limited to 'src/SMAPI.Web/Framework')
33 files changed, 611 insertions, 690 deletions
diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs index 004202f9..0d912c7b 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -1,6 +1,6 @@ using System; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; +using StardewModdingAPI.Web.Framework.Clients; namespace StardewModdingAPI.Web.Framework.Caching.Mods { @@ -15,13 +15,13 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> /// <param name="mod">The fetched mod.</param> /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param> - bool TryGetMod(ModRepositoryKey site, string id, out Cached<ModInfoModel> mod, bool markRequested = true); + bool TryGetMod(ModSiteKey site, string id, out Cached<IModPage> mod, bool markRequested = true); /// <summary>Save data fetched for a mod.</summary> /// <param name="site">The mod site on which the mod is found.</param> /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> /// <param name="mod">The mod data.</param> - void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod); + void SaveMod(ModSiteKey site, string id, IModPage mod); /// <summary>Delete data for mods which haven't been requested within a given time limit.</summary> /// <param name="age">The minimum age for which to remove mods.</param> diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs index 62461116..6b0ec1ec 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.ModRepositories; +using StardewModdingAPI.Web.Framework.Clients; namespace StardewModdingAPI.Web.Framework.Caching.Mods { @@ -13,7 +13,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods ** Fields *********/ /// <summary>The cached mod data indexed by <c>{site key}:{ID}</c>.</summary> - private readonly IDictionary<string, Cached<ModInfoModel>> Mods = new Dictionary<string, Cached<ModInfoModel>>(StringComparer.InvariantCultureIgnoreCase); + private readonly IDictionary<string, Cached<IModPage>> Mods = new Dictionary<string, Cached<IModPage>>(StringComparer.InvariantCultureIgnoreCase); /********* @@ -24,7 +24,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> /// <param name="mod">The fetched mod.</param> /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param> - public bool TryGetMod(ModRepositoryKey site, string id, out Cached<ModInfoModel> mod, bool markRequested = true) + public bool TryGetMod(ModSiteKey site, string id, out Cached<IModPage> mod, bool markRequested = true) { // get mod if (!this.Mods.TryGetValue(this.GetKey(site, id), out var cachedMod)) @@ -45,10 +45,10 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// <param name="site">The mod site on which the mod is found.</param> /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> /// <param name="mod">The mod data.</param> - public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod) + public void SaveMod(ModSiteKey site, string id, IModPage mod) { string key = this.GetKey(site, id); - this.Mods[key] = new Cached<ModInfoModel>(mod); + this.Mods[key] = new Cached<IModPage>(mod); } /// <summary>Delete data for mods which haven't been requested within a given time limit.</summary> @@ -73,7 +73,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// <summary>Get a cache key.</summary> /// <param name="site">The mod site.</param> /// <param name="id">The mod ID.</param> - private string GetKey(ModRepositoryKey site, string id) + private string GetKey(ModSiteKey site, string id) { return $"{site}:{id.Trim()}".ToLower(); } diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs index cdb281e2..ca156da4 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using HtmlAgilityPack; using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish { @@ -20,6 +21,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey => ModSiteKey.Chucklefish; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -32,42 +40,40 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); } - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The Chucklefish mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - public async Task<ChucklefishMod> GetModAsync(uint id) + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public async Task<IModPage> GetModData(string id) { + IModPage page = new GenericModPage(this.SiteKey, id); + + // get mod ID + if (!uint.TryParse(id, out uint parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); + // fetch HTML string html; try { html = await this.Client - .GetAsync(string.Format(this.ModPageUrlFormat, id)) + .GetAsync(string.Format(this.ModPageUrlFormat, parsedId)) .AsString(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound || ex.Status == HttpStatusCode.Forbidden) { - return null; + return page.SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); } - - // parse HTML var doc = new HtmlDocument(); doc.LoadHtml(html); // extract mod info - string url = this.GetModUrl(id); + string url = this.GetModUrl(parsedId); string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value; if (name.StartsWith("[SMAPI] ")) name = name.Substring("[SMAPI] ".Length); string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; - // create model - return new ChucklefishMod - { - Name = name, - Version = version, - Url = url - }; + // return info + return page.SetInfo(name: name, version: version, url: url, downloads: Array.Empty<IModDownload>()); } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs deleted file mode 100644 index fd0101d4..00000000 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish -{ - /// <summary>Mod metadata from the Chucklefish mod site.</summary> - internal class ChucklefishMod - { - /********* - ** Accessors - *********/ - /// <summary>The mod name.</summary> - public string Name { get; set; } - - /// <summary>The mod's semantic version number.</summary> - public string Version { get; set; } - - /// <summary>The mod's web URL.</summary> - public string Url { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs index 1d8b256e..836d43f7 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish { /// <summary>An HTTP client for fetching mod metadata from the Chucklefish mod site.</summary> - internal interface IChucklefishClient : IDisposable - { - /********* - ** Methods - *********/ - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The Chucklefish mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - Task<ChucklefishMod> GetModAsync(uint id); - } + internal interface IChucklefishClient : IModSiteClient, IDisposable { } } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs index a6fd21fd..d8008721 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -1,8 +1,8 @@ -using System.Linq; +using System.Collections.Generic; using System.Text.RegularExpressions; using System.Threading.Tasks; using Pathoschild.Http.Client; -using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; namespace StardewModdingAPI.Web.Framework.Clients.CurseForge @@ -21,6 +21,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey => ModSiteKey.CurseForge; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -31,59 +38,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent); } - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The CurseForge mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - public async Task<CurseForgeMod> GetModAsync(long id) + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public async Task<IModPage> GetModData(string id) { + IModPage page = new GenericModPage(this.SiteKey, id); + + // get ID + if (!uint.TryParse(id, out uint parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); + // get raw data ModModel mod = await this.Client - .GetAsync($"addon/{id}") + .GetAsync($"addon/{parsedId}") .As<ModModel>(); if (mod == null) - return null; + return page.SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); - // get latest versions - string invalidVersion = null; - ISemanticVersion latest = null; + // get downloads + List<IModDownload> downloads = new List<IModDownload>(); foreach (ModFileModel file in mod.LatestFiles) { - // extract version - ISemanticVersion version; - { - string raw = this.GetRawVersion(file); - if (raw == null) - continue; - - if (!SemanticVersion.TryParse(raw, out version)) - { - invalidVersion ??= raw; - continue; - } - } - - // track latest version - if (latest == null || version.IsNewerThan(latest)) - latest = version; - } - - // get error - string error = null; - if (latest == null && invalidVersion == null) - { - error = mod.LatestFiles.Any() - ? $"CurseForge mod {id} has no downloads which specify the version in a recognised format." - : $"CurseForge mod {id} has no downloads."; + downloads.Add( + new GenericModDownload(name: file.DisplayName ?? file.FileName, description: null, version: this.GetRawVersion(file)) + ); } - // generate result - return new CurseForgeMod - { - Name = mod.Name, - LatestVersion = latest?.ToString() ?? invalidVersion, - Url = mod.WebsiteUrl, - Error = error - }; + // return info + return page.SetInfo(name: mod.Name, version: null, url: mod.WebsiteUrl, downloads: downloads); } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs deleted file mode 100644 index e5bb8cf1..00000000 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Newtonsoft.Json; - -namespace StardewModdingAPI.Web.Framework.Clients.CurseForge -{ - /// <summary>Mod metadata from the CurseForge API.</summary> - internal class CurseForgeMod - { - /********* - ** Accessors - *********/ - /// <summary>The mod name.</summary> - public string Name { get; set; } - - /// <summary>The latest file version.</summary> - public string LatestVersion { get; set; } - - /// <summary>The mod's web URL.</summary> - public string Url { get; set; } - - /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> - public string Error { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs index 907b4087..2018c230 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.CurseForge { /// <summary>An HTTP client for fetching mod metadata from the CurseForge API.</summary> - internal interface ICurseForgeClient : IDisposable - { - /********* - ** Methods - *********/ - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The CurseForge mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - Task<CurseForgeMod> GetModAsync(long id); - } + internal interface ICurseForgeClient : IModSiteClient, IDisposable { } } diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs new file mode 100644 index 00000000..f08b471c --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs @@ -0,0 +1,36 @@ +namespace StardewModdingAPI.Web.Framework.Clients +{ + /// <summary>Generic metadata about a file download on a mod page.</summary> + internal class GenericModDownload : IModDownload + { + /********* + ** Accessors + *********/ + /// <summary>The download's display name.</summary> + public string Name { get; set; } + + /// <summary>The download's description.</summary> + public string Description { get; set; } + + /// <summary>The download's file version.</summary> + public string Version { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an empty instance.</summary> + public GenericModDownload() { } + + /// <summary>Construct an instance.</summary> + /// <param name="name">The download's display name.</param> + /// <param name="description">The download's description.</param> + /// <param name="version">The download's file version.</param> + public GenericModDownload(string name, string description, string version) + { + this.Name = name; + this.Description = description; + this.Version = version; + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs new file mode 100644 index 00000000..622e6c56 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Clients +{ + /// <summary>Generic metadata about a mod page.</summary> + internal class GenericModPage : IModPage + { + /********* + ** Accessors + *********/ + /// <summary>The mod site containing the mod.</summary> + public ModSiteKey Site { get; set; } + + /// <summary>The mod's unique ID within the site.</summary> + public string Id { get; set; } + + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>The mod's semantic version number.</summary> + public string Version { get; set; } + + /// <summary>The mod's web URL.</summary> + public string Url { get; set; } + + /// <summary>The mod downloads.</summary> + public IModDownload[] Downloads { get; set; } = new IModDownload[0]; + + /// <summary>The mod availability status on the remote site.</summary> + public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + + /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> + public string Error { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an empty instance.</summary> + public GenericModPage() { } + + /// <summary>Construct an instance.</summary> + /// <param name="site">The mod site containing the mod.</param> + /// <param name="id">The mod's unique ID within the site.</param> + public GenericModPage(ModSiteKey site, string id) + { + this.Site = site; + this.Id = id; + } + + /// <summary>Set the fetched mod info.</summary> + /// <param name="name">The mod name.</param> + /// <param name="version">The mod's semantic version number.</param> + /// <param name="url">The mod's web URL.</param> + /// <param name="downloads">The mod downloads.</param> + public IModPage SetInfo(string name, string version, string url, IEnumerable<IModDownload> downloads) + { + this.Name = name; + this.Version = version; + this.Url = url; + this.Downloads = downloads.ToArray(); + + return this; + } + + /// <summary>Set a mod fetch error.</summary> + /// <param name="status">The mod availability status on the remote site.</param> + /// <param name="error">A user-friendly error which indicates why fetching the mod info failed (if applicable).</param> + public IModPage SetError(RemoteModStatus status, string error) + { + this.Status = status; + this.Error = error; + + return this; + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 84c20957..2f1eb854 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework.Clients.GitHub { @@ -17,6 +18,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey => ModSiteKey.GitHub; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -79,6 +87,54 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub } } + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public async Task<IModPage> GetModData(string id) + { + IModPage page = new GenericModPage(this.SiteKey, id); + + if (!id.Contains("/") || id.IndexOf("/", StringComparison.OrdinalIgnoreCase) != id.LastIndexOf("/", StringComparison.OrdinalIgnoreCase)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/SMAPI'."); + + // fetch repo info + GitRepo repository = await this.GetRepositoryAsync(id); + if (repository == null) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); + string name = repository.FullName; + string url = $"{repository.WebUrl}/releases"; + + // get releases + GitRelease latest; + GitRelease preview; + { + // get latest release (whether preview or stable) + latest = await this.GetLatestReleaseAsync(id, includePrerelease: true); + if (latest == null) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID."); + + // get stable version if different + preview = null; + if (latest.IsPrerelease) + { + GitRelease release = await this.GetLatestReleaseAsync(id, includePrerelease: false); + if (release != null) + { + preview = latest; + latest = release; + } + } + } + + // get downloads + IModDownload[] downloads = new[] { latest, preview } + .Where(release => release != null) + .Select(release => (IModDownload)new GenericModDownload(release.Name, release.Body, release.Tag)) + .ToArray(); + + // return info + return page.SetInfo(name: name, url: url, version: null, downloads: downloads); + } + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index a34f03bd..0d6f4643 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.GitHub { /// <summary>An HTTP client for fetching metadata from GitHub.</summary> - internal interface IGitHubClient : IDisposable + internal interface IGitHubClient : IModSiteClient, IDisposable { /********* ** Methods diff --git a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs new file mode 100644 index 00000000..33277711 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Clients +{ + /// <summary>A client for fetching update check info from a mod site.</summary> + internal interface IModSiteClient + { + /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey { get; } + + + /********* + ** Methods + *********/ + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + Task<IModPage> GetModData(string id); + } +} diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs index 3ede46e2..468b72b1 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.ModDrop { /// <summary>An HTTP client for fetching mod metadata from the ModDrop API.</summary> - internal interface IModDropClient : IDisposable - { - /********* - ** Methods - *********/ - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The ModDrop mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - Task<ModDropMod> GetModAsync(long id); - } + internal interface IModDropClient : IDisposable, IModSiteClient { } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs index 5ad2d2f8..3a1c5b9d 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs @@ -1,6 +1,7 @@ +using System.Collections.Generic; using System.Threading.Tasks; using Pathoschild.Http.Client; -using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels; namespace StardewModdingAPI.Web.Framework.Clients.ModDrop @@ -19,6 +20,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey => ModSiteKey.ModDrop; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -31,60 +39,45 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop this.ModUrlFormat = modUrlFormat; } - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The ModDrop mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - public async Task<ModDropMod> GetModAsync(long id) + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public async Task<IModPage> GetModData(string id) { + var page = new GenericModPage(this.SiteKey, id); + + if (!long.TryParse(id, out long parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); + // get raw data ModListModel response = await this.Client .PostAsync("") .WithBody(new { - ModIDs = new[] { id }, + ModIDs = new[] { parsedId }, Files = true, Mods = true }) .As<ModListModel>(); - ModModel mod = response.Mods[id]; + ModModel mod = response.Mods[parsedId]; if (mod.Mod?.Title == null || mod.Mod.ErrorCode.HasValue) return null; - // get latest versions - ISemanticVersion latest = null; - ISemanticVersion optional = null; + // get files + var downloads = new List<IModDownload>(); foreach (FileDataModel file in mod.Files) { if (file.IsOld || file.IsDeleted || file.IsHidden) continue; - if (!SemanticVersion.TryParse(file.Version, out ISemanticVersion version)) - continue; - - if (file.IsDefault) - { - if (latest == null || version.IsNewerThan(latest)) - latest = version; - } - else if (optional == null || version.IsNewerThan(optional)) - optional = version; + downloads.Add( + new GenericModDownload(file.Name, file.Description, file.Version) + ); } - if (latest == null) - { - latest = optional; - optional = null; - } - if (optional != null && latest.IsNewerThan(optional)) - optional = null; - // generate result - return new ModDropMod - { - Name = mod.Mod?.Title, - LatestDefaultVersion = latest, - LatestOptionalVersion = optional, - Url = string.Format(this.ModUrlFormat, id) - }; + // return info + string name = mod.Mod?.Title; + string url = string.Format(this.ModUrlFormat, id); + return page.SetInfo(name: name, version: null, url: url, downloads: downloads); } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs deleted file mode 100644 index def79106..00000000 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.Clients.ModDrop -{ - /// <summary>Mod metadata from the ModDrop API.</summary> - internal class ModDropMod - { - /********* - ** Accessors - *********/ - /// <summary>The mod name.</summary> - public string Name { get; set; } - - /// <summary>The latest default file version.</summary> - public ISemanticVersion LatestDefaultVersion { get; set; } - - /// <summary>The latest optional file version.</summary> - public ISemanticVersion LatestOptionalVersion { get; set; } - - /// <summary>The mod's web URL.</summary> - public string Url { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs index fa84b287..b01196f4 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs @@ -1,8 +1,21 @@ +using Newtonsoft.Json; + namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels { /// <summary>Metadata from the ModDrop API about a mod file.</summary> public class FileDataModel { + /// <summary>The file title.</summary> + [JsonProperty("title")] + public string Name { get; set; } + + /// <summary>The file description.</summary> + [JsonProperty("desc")] + public string Description { get; set; } + + /// <summary>The file version.</summary> + public string Version { get; set; } + /// <summary>Whether the file is deleted.</summary> public bool IsDeleted { get; set; } @@ -14,8 +27,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// <summary>Whether this is an archived file.</summary> public bool IsOld { get; set; } - - /// <summary>The file version.</summary> - public string Version { get; set; } } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs index e56e7af4..a44b8c66 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs @@ -1,17 +1,7 @@ using System; -using System.Threading.Tasks; namespace StardewModdingAPI.Web.Framework.Clients.Nexus { /// <summary>An HTTP client for fetching mod metadata from Nexus Mods.</summary> - internal interface INexusClient : IDisposable - { - /********* - ** Methods - *********/ - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The Nexus mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - Task<NexusMod> GetModAsync(uint id); - } + internal interface INexusClient : IModSiteClient, IDisposable { } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs index 753d3b4f..ef3ef22e 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -7,6 +7,8 @@ using HtmlAgilityPack; using Pathoschild.FluentNexus.Models; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels; using FluentNexusClient = Pathoschild.FluentNexus.NexusClient; namespace StardewModdingAPI.Web.Framework.Clients.Nexus @@ -31,6 +33,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /********* + ** Accessors + *********/ + /// <summary>The unique key for the mod site.</summary> + public ModSiteKey SiteKey => ModSiteKey.Nexus; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -48,20 +57,32 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion); } - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The Nexus mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - public async Task<NexusMod> GetModAsync(uint id) + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public async Task<IModPage> GetModData(string id) { + IModPage page = new GenericModPage(this.SiteKey, id); + + if (!uint.TryParse(id, out uint parsedId)) + return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); + // Fetch from the Nexus website when possible, since it has no rate limits. Mods with // adult content are hidden for anonymous users, so fall back to the API in that case. // Note that the API has very restrictive rate limits which means we can't just use it // for all cases. - NexusMod mod = await this.GetModFromWebsiteAsync(id); + NexusMod mod = await this.GetModFromWebsiteAsync(parsedId); if (mod?.Status == NexusModStatus.AdultContentForbidden) - mod = await this.GetModFromApiAsync(id); + mod = await this.GetModFromApiAsync(parsedId); + + // page doesn't exist + if (mod == null || mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); - return mod; + // return info + page.SetInfo(name: mod.Name, url: mod.Url, version: mod.Version, downloads: mod.Downloads); + if (mod.Status != NexusModStatus.Ok) + page.SetError(RemoteModStatus.TemporaryError, mod.Error); + return page; } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> @@ -115,37 +136,28 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus // extract mod info string url = this.GetModUrl(id); - string name = doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim(); + string name = doc.DocumentNode.SelectSingleNode("//div[@id='pagetitle']//h1")?.InnerText.Trim(); string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion); - // extract file versions - List<string> rawVersions = new List<string>(); + // extract files + var downloads = new List<IModDownload>(); foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) { string sectionName = fileSection.Descendants("h2").First().InnerText; if (sectionName != "Main files" && sectionName != "Optional files") continue; - rawVersions.AddRange( - from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version")) - from versionStat in statBox.Descendants().Where(p => p.HasClass("stat")) - select versionStat.InnerText.Trim() - ); - } - - // choose latest file version - ISemanticVersion latestFileVersion = null; - foreach (string rawVersion in rawVersions) - { - if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) - continue; - if (parsedVersion != null && !cur.IsNewerThan(parsedVersion)) - continue; - if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) - continue; + foreach (var container in fileSection.Descendants("dt")) + { + string fileName = container.GetDataAttribute("name").Value; + string fileVersion = container.GetDataAttribute("version").Value; + string description = container.SelectSingleNode("following-sibling::*[1][self::dd]//div").InnerText?.Trim(); // get text of next <dd> tag; derived from https://stackoverflow.com/a/25535623/262123 - latestFileVersion = cur; + downloads.Add( + new GenericModDownload(fileName, description, fileVersion) + ); + } } // yield info @@ -153,8 +165,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus { Name = name, Version = parsedVersion?.ToString() ?? version, - LatestFileVersion = latestFileVersion, - Url = url + Url = url, + Downloads = downloads.ToArray() }; } @@ -167,29 +179,15 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus Mod mod = await this.ApiClient.Mods.GetMod("stardewvalley", (int)id); ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); - // get versions - if (!SemanticVersion.TryParse(mod.Version, out ISemanticVersion mainVersion)) - mainVersion = null; - ISemanticVersion latestFileVersion = null; - foreach (string rawVersion in files.Files.Select(p => p.FileVersion)) - { - if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) - continue; - if (mainVersion != null && !cur.IsNewerThan(mainVersion)) - continue; - if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) - continue; - - latestFileVersion = cur; - } - // yield info return new NexusMod { Name = mod.Name, Version = SemanticVersion.TryParse(mod.Version, out ISemanticVersion version) ? version?.ToString() : mod.Version, - LatestFileVersion = latestFileVersion, - Url = this.GetModUrl(id) + Url = this.GetModUrl(id), + Downloads = files.Files + .Select(file => (IModDownload)new GenericModDownload(file.Name, null, file.FileVersion)) + .ToArray() }; } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs index 0f1b29d5..aef90ede 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace StardewModdingAPI.Web.Framework.Clients.Nexus +namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels { /// <summary>Mod metadata from Nexus Mods.</summary> internal class NexusMod @@ -14,9 +14,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /// <summary>The mod's semantic version number.</summary> public string Version { get; set; } - /// <summary>The latest file version.</summary> - public ISemanticVersion LatestFileVersion { get; set; } - /// <summary>The mod's web URL.</summary> [JsonProperty("mod_page_uri")] public string Url { get; set; } @@ -25,7 +22,11 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus [JsonIgnore] public NexusModStatus Status { get; set; } = NexusModStatus.Ok; - /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> + /// <summary>The files available to download.</summary> + [JsonIgnore] + public IModDownload[] Downloads { get; set; } + + /// <summary>A custom user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> [JsonIgnore] public string Error { get; set; } } diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs index ad7e645a..3a246245 100644 --- a/src/SMAPI.Web/Framework/Extensions.cs +++ b/src/SMAPI.Web/Framework/Extensions.cs @@ -13,6 +13,12 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Provides extensions on ASP.NET Core types.</summary> public static class Extensions { + /********* + ** Public methods + *********/ + /**** + ** View helpers + ****/ /// <summary>Get a URL with the absolute path for an action method. Unlike <see cref="IUrlHelper.Action"/>, only the specified <paramref name="values"/> are added to the URL without merging values from the current HTTP request.</summary> /// <param name="helper">The URL helper to extend.</param> /// <param name="action">The name of the action method.</param> diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs new file mode 100644 index 00000000..dc058bcb --- /dev/null +++ b/src/SMAPI.Web/Framework/IModDownload.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>Generic metadata about a file download on a mod page.</summary> + internal interface IModDownload + { + /// <summary>The download's display name.</summary> + string Name { get; } + + /// <summary>The download's description.</summary> + string Description { get; } + + /// <summary>The download's file version.</summary> + string Version { get; } + } +} diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs new file mode 100644 index 00000000..e66d401f --- /dev/null +++ b/src/SMAPI.Web/Framework/IModPage.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>Generic metadata about a mod page.</summary> + internal interface IModPage + { + /********* + ** Accessors + *********/ + /// <summary>The mod site containing the mod.</summary> + ModSiteKey Site { get; } + + /// <summary>The mod's unique ID within the site.</summary> + string Id { get; } + + /// <summary>The mod name.</summary> + string Name { get; } + + /// <summary>The mod's semantic version number.</summary> + string Version { get; } + + /// <summary>The mod's web URL.</summary> + string Url { get; } + + /// <summary>The mod downloads.</summary> + IModDownload[] Downloads { get; } + + /// <summary>The mod page status.</summary> + RemoteModStatus Status { get; } + + /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> + string Error { get; } + + + /********* + ** Methods + *********/ + /// <summary>Set the fetched mod info.</summary> + /// <param name="name">The mod name.</param> + /// <param name="version">The mod's semantic version number.</param> + /// <param name="url">The mod's web URL.</param> + /// <param name="downloads">The mod downloads.</param> + IModPage SetInfo(string name, string version, string url, IEnumerable<IModDownload> downloads); + + /// <summary>Set a mod fetch error.</summary> + /// <param name="status">The mod availability status on the remote site.</param> + /// <param name="error">A user-friendly error which indicates why fetching the mod info failed (if applicable).</param> + IModPage SetError(RemoteModStatus status, string error); + } +} diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs index 46b98860..7845b8c5 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModInfoModel.cs @@ -1,4 +1,6 @@ -namespace StardewModdingAPI.Web.Framework.ModRepositories +using StardewModdingAPI.Web.Framework.Clients; + +namespace StardewModdingAPI.Web.Framework { /// <summary>Generic metadata about a mod.</summary> internal class ModInfoModel @@ -10,20 +12,14 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories public string Name { get; set; } /// <summary>The mod's latest version.</summary> - public string Version { get; set; } + public ISemanticVersion Version { get; set; } /// <summary>The mod's latest optional or prerelease version, if newer than <see cref="Version"/>.</summary> - public string PreviewVersion { get; set; } + public ISemanticVersion PreviewVersion { get; set; } /// <summary>The mod's web URL.</summary> public string Url { get; set; } - /// <summary>The license URL, if available.</summary> - public string LicenseUrl { get; set; } - - /// <summary>The license name, if available.</summary> - public string LicenseName { get; set; } - /// <summary>The mod availability status on the remote site.</summary> public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; @@ -42,7 +38,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /// <param name="version">The semantic version for the mod's latest release.</param> /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param> /// <param name="url">The mod's web URL.</param> - public ModInfoModel(string name, string version, string url, string previewVersion = null) + public ModInfoModel(string name, ISemanticVersion version, string url, ISemanticVersion previewVersion = null) { this .SetBasicInfo(name, url) @@ -63,7 +59,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /// <summary>Set the mod version info.</summary> /// <param name="version">The semantic version for the mod's latest release.</param> /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param> - public ModInfoModel SetVersions(string version, string previewVersion = null) + public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion previewVersion = null) { this.Version = version; this.PreviewVersion = previewVersion; @@ -71,17 +67,6 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories return this; } - /// <summary>Set the license info, if available.</summary> - /// <param name="url">The license URL.</param> - /// <param name="name">The license name.</param> - public ModInfoModel SetLicense(string url, string name) - { - this.LicenseUrl = url; - this.LicenseName = name; - - return this; - } - /// <summary>Set a mod error.</summary> /// <param name="status">The mod availability status on the remote site.</param> /// <param name="error">The error message indicating why the mod is invalid (if applicable).</param> diff --git a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs deleted file mode 100644 index f9f9f47d..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - internal abstract class RepositoryBase : IModRepository - { - /********* - ** Accessors - *********/ - /// <summary>The unique key for this vendor.</summary> - public ModRepositoryKey VendorKey { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public abstract void Dispose(); - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public abstract Task<ModInfoModel> GetModInfoAsync(string id); - - - /********* - ** Protected methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="vendorKey">The unique key for this vendor.</param> - protected RepositoryBase(ModRepositoryKey vendorKey) - { - this.VendorKey = vendorKey; - } - - /// <summary>Normalize a version string.</summary> - /// <param name="version">The version to normalize.</param> - protected string NormalizeVersion(string version) - { - if (string.IsNullOrWhiteSpace(version)) - return null; - - version = version.Trim(); - if (Regex.IsMatch(version, @"^v\d", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) // common version prefix - version = version.Substring(1); - - return version; - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs deleted file mode 100644 index 0945735a..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.Chucklefish; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// <summary>An HTTP client for fetching mod metadata from the Chucklefish mod site.</summary> - internal class ChucklefishRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// <summary>The underlying HTTP client.</summary> - private readonly IChucklefishClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="client">The underlying HTTP client.</param> - public ChucklefishRepository(IChucklefishClient client) - : base(ModRepositoryKey.Chucklefish) - { - this.Client = client; - } - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public override async Task<ModInfoModel> GetModInfoAsync(string id) - { - // validate ID format - if (!uint.TryParse(id, out uint realID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); - - // fetch info - try - { - var mod = await this.Client.GetModAsync(realID); - return mod != null - ? new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), url: mod.Url) - : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs deleted file mode 100644 index 93ddc1eb..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.CurseForge; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// <summary>An HTTP client for fetching mod metadata from CurseForge.</summary> - internal class CurseForgeRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// <summary>The underlying CurseForge API client.</summary> - private readonly ICurseForgeClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="client">The underlying CurseForge API client.</param> - public CurseForgeRepository(ICurseForgeClient client) - : base(ModRepositoryKey.CurseForge) - { - this.Client = client; - } - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public override async Task<ModInfoModel> GetModInfoAsync(string id) - { - // validate ID format - if (!uint.TryParse(id, out uint curseID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); - - // fetch info - try - { - CurseForgeMod mod = await this.Client.GetModAsync(curseID); - if (mod == null) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); - if (mod.Error != null) - { - RemoteModStatus remoteStatus = RemoteModStatus.InvalidData; - return new ModInfoModel().SetError(remoteStatus, mod.Error); - } - - return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.LatestVersion), url: mod.Url); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs deleted file mode 100644 index c62cb73f..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.GitHub; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// <summary>An HTTP client for fetching mod metadata from GitHub project releases.</summary> - internal class GitHubRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// <summary>The underlying GitHub API client.</summary> - private readonly IGitHubClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="client">The underlying GitHub API client.</param> - public GitHubRepository(IGitHubClient client) - : base(ModRepositoryKey.GitHub) - { - this.Client = client; - } - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public override async Task<ModInfoModel> GetModInfoAsync(string id) - { - ModInfoModel result = new ModInfoModel().SetBasicInfo(id, $"https://github.com/{id}/releases"); - - // validate ID format - if (!id.Contains("/") || id.IndexOf("/", StringComparison.InvariantCultureIgnoreCase) != id.LastIndexOf("/", StringComparison.InvariantCultureIgnoreCase)) - return result.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/LookupAnything'."); - - // fetch info - try - { - // fetch repo info - GitRepo repository = await this.Client.GetRepositoryAsync(id); - if (repository == null) - return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); - result - .SetBasicInfo(repository.FullName, $"{repository.WebUrl}/releases") - .SetLicense(url: repository.License?.Url, name: repository.License?.SpdxId ?? repository.License?.Name); - - // get latest release (whether preview or stable) - GitRelease latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: true); - if (latest == null) - return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID."); - - // split stable/prerelease if applicable - GitRelease preview = null; - if (latest.IsPrerelease) - { - GitRelease release = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false); - if (release != null) - { - preview = latest; - latest = release; - } - } - - // return data - return result.SetVersions(version: this.NormalizeVersion(latest.Tag), previewVersion: this.NormalizeVersion(preview?.Tag)); - } - catch (Exception ex) - { - return result.SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs deleted file mode 100644 index 68f754ae..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// <summary>A repository which provides mod metadata.</summary> - internal interface IModRepository : IDisposable - { - /********* - ** Accessors - *********/ - /// <summary>The unique key for this vendor.</summary> - ModRepositoryKey VendorKey { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - Task<ModInfoModel> GetModInfoAsync(string id); - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs deleted file mode 100644 index 62142668..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.ModDrop; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// <summary>An HTTP client for fetching mod metadata from the ModDrop API.</summary> - internal class ModDropRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// <summary>The underlying ModDrop API client.</summary> - private readonly IModDropClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="client">The underlying Nexus Mods API client.</param> - public ModDropRepository(IModDropClient client) - : base(ModRepositoryKey.ModDrop) - { - this.Client = client; - } - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public override async Task<ModInfoModel> GetModInfoAsync(string id) - { - // validate ID format - if (!long.TryParse(id, out long modDropID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); - - // fetch info - try - { - ModDropMod mod = await this.Client.GetModAsync(modDropID); - return mod != null - ? new ModInfoModel(name: mod.Name, version: mod.LatestDefaultVersion?.ToString(), previewVersion: mod.LatestOptionalVersion?.ToString(), url: mod.Url) - : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop mod with this ID."); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs deleted file mode 100644 index 9551258c..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients.Nexus; - -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// <summary>An HTTP client for fetching mod metadata from Nexus Mods.</summary> - internal class NexusRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// <summary>The underlying Nexus Mods API client.</summary> - private readonly INexusClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="client">The underlying Nexus Mods API client.</param> - public NexusRepository(INexusClient client) - : base(ModRepositoryKey.Nexus) - { - this.Client = client; - } - - /// <summary>Get metadata about a mod in the repository.</summary> - /// <param name="id">The mod ID in this repository.</param> - public override async Task<ModInfoModel> GetModInfoAsync(string id) - { - // validate ID format - if (!uint.TryParse(id, out uint nexusID)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); - - // fetch info - try - { - NexusMod mod = await this.Client.GetModAsync(nexusID); - if (mod == null) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); - if (mod.Error != null) - { - RemoteModStatus remoteStatus = mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished - ? RemoteModStatus.DoesNotExist - : RemoteModStatus.TemporaryError; - return new ModInfoModel().SetError(remoteStatus, mod.Error); - } - - return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), previewVersion: mod.LatestFileVersion?.ToString(), url: mod.Url); - } - catch (Exception ex) - { - return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs new file mode 100644 index 00000000..eaae7935 --- /dev/null +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Clients; + +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>Handles fetching data from mod sites.</summary> + internal class ModSiteManager + { + /********* + ** Fields + *********/ + /// <summary>The mod sites which provide mod metadata.</summary> + private readonly IDictionary<ModSiteKey, IModSiteClient> ModSites; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modSites">The mod sites which provide mod metadata.</param> + public ModSiteManager(IModSiteClient[] modSites) + { + this.ModSites = modSites.ToDictionary(p => p.SiteKey); + } + + /// <summary>Get the mod info for an update key.</summary> + /// <param name="updateKey">The namespaced update key.</param> + public async Task<IModPage> GetModPageAsync(UpdateKey updateKey) + { + // get site + if (!this.ModSites.TryGetValue(updateKey.Site, out IModSiteClient client)) + return new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Site}'. Expected one of [{string.Join(", ", this.ModSites.Keys)}]."); + + // fetch mod + IModPage mod; + try + { + mod = await client.GetModData(updateKey.ID); + } + catch (Exception ex) + { + mod = new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.TemporaryError, ex.ToString()); + } + + // handle errors + return mod ?? new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.DoesNotExist, $"Found no {updateKey.Site} mod with ID '{updateKey.ID}'."); + } + + /// <summary>Parse version info for the given mod page info.</summary> + /// <param name="page">The mod page info.</param> + /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param> + /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> + public ModInfoModel GetPageVersions(IModPage page, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions) + { + // get base model + ModInfoModel model = new ModInfoModel() + .SetBasicInfo(page.Name, page.Url) + .SetError(page.Status, page.Error); + if (page.Status != RemoteModStatus.Ok) + return model; + + // fetch versions + if (!this.TryGetLatestVersions(page, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion mainVersion, out ISemanticVersion previewVersion)) + return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}' has no valid versions."); + + // return info + return model.SetVersions(mainVersion, previewVersion); + } + + /// <summary>Get a semantic local version for update checks.</summary> + /// <param name="version">The version to parse.</param> + /// <param name="map">A map of version replacements.</param> + /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> + public ISemanticVersion GetMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) + { + // try mapped version + string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard); + if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion parsedNew)) + return parsedNew; + + // return original version + return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsedOld) + ? parsedOld + : null; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the mod version numbers for the given mod.</summary> + /// <param name="mod">The mod to check.</param> + /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> + /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param> + /// <param name="main">The main mod version.</param> + /// <param name="preview">The latest prerelease version, if newer than <paramref name="main"/>.</param> + private bool TryGetLatestVersions(IModPage mod, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) + { + main = null; + preview = null; + + ISemanticVersion ParseVersion(string raw) + { + raw = this.NormalizeVersion(raw); + return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions); + } + + if (mod != null) + { + // get versions + main = ParseVersion(mod.Version); + foreach (string rawVersion in mod.Downloads.Select(p => p.Version)) + { + ISemanticVersion cur = ParseVersion(rawVersion); + if (cur == null) + continue; + + if (main == null || cur.IsNewerThan(main)) + main = cur; + if (cur.IsPrerelease() && (preview == null || cur.IsNewerThan(preview))) + preview = cur; + } + + if (preview != null && !preview.IsNewerThan(main)) + preview = null; + } + + return main != null; + } + + /// <summary>Get a semantic local version for update checks.</summary> + /// <param name="version">The version to map.</param> + /// <param name="map">A map of version replacements.</param> + /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> + private string GetRawMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) + { + if (version == null || map == null || !map.Any()) + return version; + + // match exact raw version + if (map.ContainsKey(version)) + return map[version]; + + // match parsed version + if (SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsed)) + { + if (map.ContainsKey(parsed.ToString())) + return map[parsed.ToString()]; + + foreach ((string fromRaw, string toRaw) in map) + { + if (SemanticVersion.TryParse(fromRaw, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(toRaw, allowNonStandard, out ISemanticVersion newVersion)) + return newVersion.ToString(); + } + } + + return version; + } + + /// <summary>Normalize a version string.</summary> + /// <param name="version">The version to normalize.</param> + private string NormalizeVersion(string version) + { + if (string.IsNullOrWhiteSpace(version)) + return null; + + version = version.Trim(); + if (Regex.IsMatch(version, @"^v\d", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) // common version prefix + version = version.Substring(1); + + return version; + } + } +} diff --git a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs b/src/SMAPI.Web/Framework/RemoteModStatus.cs index 02876556..139ecfd3 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs +++ b/src/SMAPI.Web/Framework/RemoteModStatus.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Web.Framework.ModRepositories +namespace StardewModdingAPI.Web.Framework { /// <summary>The mod availability status on a remote site.</summary> internal enum RemoteModStatus |