summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web/Framework
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2020-05-23 21:55:11 -0400
committerJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2020-05-23 21:55:11 -0400
commit786077340f2cea37d82455fc413535ae82a912ee (patch)
tree588d6755b1001bd7eb218dcf9b332feb933e180b /src/SMAPI.Web/Framework
parentd7add894419543667e60569bfeb439e8e797a4d1 (diff)
downloadSMAPI-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')
-rw-r--r--src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs6
-rw-r--r--src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs12
-rw-r--r--src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs38
-rw-r--r--src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs18
-rw-r--r--src/SMAPI.Web/Framework/Clients/Chucklefish/IChucklefishClient.cs12
-rw-r--r--src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs72
-rw-r--r--src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs23
-rw-r--r--src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs12
-rw-r--r--src/SMAPI.Web/Framework/Clients/GenericModDownload.cs36
-rw-r--r--src/SMAPI.Web/Framework/Clients/GenericModPage.cs79
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs56
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs2
-rw-r--r--src/SMAPI.Web/Framework/Clients/IModSiteClient.cs23
-rw-r--r--src/SMAPI.Web/Framework/Clients/ModDrop/IModDropClient.cs12
-rw-r--r--src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs63
-rw-r--r--src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs21
-rw-r--r--src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs16
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/INexusClient.cs12
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs94
-rw-r--r--src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs (renamed from src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs)11
-rw-r--r--src/SMAPI.Web/Framework/Extensions.cs6
-rw-r--r--src/SMAPI.Web/Framework/IModDownload.cs15
-rw-r--r--src/SMAPI.Web/Framework/IModPage.cs52
-rw-r--r--src/SMAPI.Web/Framework/ModInfoModel.cs (renamed from src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs)29
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs51
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs57
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs63
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs82
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs24
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs57
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs65
-rw-r--r--src/SMAPI.Web/Framework/ModSiteManager.cs180
-rw-r--r--src/SMAPI.Web/Framework/RemoteModStatus.cs (renamed from src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs)2
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