summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web/Controllers
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI.Web/Controllers')
-rw-r--r--src/SMAPI.Web/Controllers/IndexController.cs180
-rw-r--r--src/SMAPI.Web/Controllers/LogParserController.cs48
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs248
3 files changed, 349 insertions, 127 deletions
diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs
index 0464e50a..8c4a0332 100644
--- a/src/SMAPI.Web/Controllers/IndexController.cs
+++ b/src/SMAPI.Web/Controllers/IndexController.cs
@@ -1,10 +1,15 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
+using HtmlAgilityPack;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
-using StardewModdingAPI.Common;
+using Microsoft.Extensions.Options;
+using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
+using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.ViewModels;
namespace StardewModdingAPI.Web.Controllers
@@ -17,6 +22,9 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Properties
*********/
+ /// <summary>The site config settings.</summary>
+ private readonly SiteConfig SiteConfig;
+
/// <summary>The cache in which to store release data.</summary>
private readonly IMemoryCache Cache;
@@ -24,7 +32,7 @@ namespace StardewModdingAPI.Web.Controllers
private readonly IGitHubClient GitHub;
/// <summary>The cache time for release info.</summary>
- private readonly TimeSpan CacheTime = TimeSpan.FromSeconds(1);
+ private readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10);
/// <summary>The GitHub repository name to check for update.</summary>
private readonly string RepositoryName = "Pathoschild/SMAPI";
@@ -36,34 +44,35 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Construct an instance.</summary>
/// <param name="cache">The cache in which to store release data.</param>
/// <param name="github">The GitHub API client.</param>
- public IndexController(IMemoryCache cache, IGitHubClient github)
+ /// <param name="siteConfig">The context config settings.</param>
+ public IndexController(IMemoryCache cache, IGitHubClient github, IOptions<SiteConfig> siteConfig)
{
this.Cache = cache;
this.GitHub = github;
+ this.SiteConfig = siteConfig.Value;
}
/// <summary>Display the index page.</summary>
[HttpGet]
public async Task<ViewResult> Index()
{
- // fetch SMAPI releases
- IndexVersionModel stableVersion = await this.Cache.GetOrCreateAsync("stable-version", async entry =>
- {
- entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime);
- GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false);
- return new IndexVersionModel(release.Name, release.Body, this.GetMainDownloadUrl(release), this.GetDevDownloadUrl(release));
- });
- IndexVersionModel betaVersion = await this.Cache.GetOrCreateAsync("beta-version", async entry =>
- {
- entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime);
- GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: true);
- return release.IsPrerelease
- ? this.GetBetaDownload(release)
- : null;
- });
+ // choose versions
+ ReleaseVersion[] versions = await this.GetReleaseVersionsAsync();
+ ReleaseVersion stableVersion = versions.LastOrDefault(version => !version.IsBeta && !version.IsForDevs);
+ ReleaseVersion stableVersionForDevs = versions.LastOrDefault(version => !version.IsBeta && version.IsForDevs);
+ ReleaseVersion betaVersion = versions.LastOrDefault(version => version.IsBeta && !version.IsForDevs);
+ ReleaseVersion betaVersionForDevs = versions.LastOrDefault(version => version.IsBeta && version.IsForDevs);
// render view
- var model = new IndexModel(stableVersion, betaVersion);
+ IndexVersionModel stableVersionModel = stableVersion != null
+ ? new IndexVersionModel(stableVersion.Version.ToString(), stableVersion.Release.Body, stableVersion.Asset.DownloadUrl, stableVersionForDevs?.Asset.DownloadUrl)
+ : new IndexVersionModel("unknown", "", "https://github.com/Pathoschild/SMAPI/releases", null); // just in case something goes wrong)
+ IndexVersionModel betaVersionModel = betaVersion != null && this.SiteConfig.EnableSmapiBeta
+ ? new IndexVersionModel(betaVersion.Version.ToString(), betaVersion.Release.Body, betaVersion.Asset.DownloadUrl, betaVersionForDevs?.Asset.DownloadUrl)
+ : null;
+
+ // render view
+ var model = new IndexModel(stableVersionModel, betaVersionModel);
return this.View(model);
}
@@ -71,62 +80,109 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Private methods
*********/
- /// <summary>Get the main download URL for a SMAPI release.</summary>
- /// <param name="release">The SMAPI release.</param>
- private string GetMainDownloadUrl(GitRelease release)
+ /// <summary>Get a sorted, parsed list of SMAPI downloads for the latest releases.</summary>
+ private async Task<ReleaseVersion[]> GetReleaseVersionsAsync()
{
- // get main download URL
- foreach (GitAsset asset in release.Assets ?? new GitAsset[0])
+ return await this.Cache.GetOrCreateAsync("available-versions", async entry =>
{
- if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer.zip"))
- return asset.DownloadUrl;
- }
+ entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime);
- // fallback just in case
- return "https://github.com/pathoschild/SMAPI/releases";
- }
+ // get latest release (whether preview or stable)
+ GitRelease stableRelease = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: true);
- /// <summary>Get the for-developers download URL for a SMAPI release.</summary>
- /// <param name="release">The SMAPI release.</param>
- private string GetDevDownloadUrl(GitRelease release)
- {
- // get dev download URL
- foreach (GitAsset asset in release.Assets ?? new GitAsset[0])
- {
- if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer-for-developers.zip"))
- return asset.DownloadUrl;
- }
+ // split stable/prerelease if applicable
+ GitRelease betaRelease = null;
+ if (stableRelease.IsPrerelease)
+ {
+ GitRelease result = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false);
+ if (result != null)
+ {
+ betaRelease = stableRelease;
+ stableRelease = result;
+ }
+ }
+
+ // strip 'noinclude' blocks from release descriptions
+ foreach (GitRelease release in new[] { stableRelease, betaRelease })
+ {
+ if (release == null)
+ continue;
+
+ HtmlDocument doc = new HtmlDocument();
+ doc.LoadHtml(release.Body);
+ foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? new HtmlNode[0])
+ node.Remove();
+ release.Body = doc.DocumentNode.InnerHtml.Trim();
+ }
- // fallback just in case
- return "https://github.com/pathoschild/SMAPI/releases";
+ // get versions
+ ReleaseVersion[] stableVersions = this.ParseReleaseVersions(stableRelease).ToArray();
+ ReleaseVersion[] betaVersions = this.ParseReleaseVersions(betaRelease).ToArray();
+ return stableVersions
+ .Concat(betaVersions)
+ .OrderBy(p => p.Version)
+ .ToArray();
+ });
}
- /// <summary>Get the latest beta download for a SMAPI release.</summary>
- /// <param name="release">The SMAPI release.</param>
- private IndexVersionModel GetBetaDownload(GitRelease release)
+ /// <summary>Get a parsed list of SMAPI downloads for a release.</summary>
+ /// <param name="release">The GitHub release.</param>
+ private IEnumerable<ReleaseVersion> ParseReleaseVersions(GitRelease release)
{
- // get download with the latest version
- SemanticVersionImpl latestVersion = null;
- string latestUrl = null;
- foreach (GitAsset asset in release.Assets ?? new GitAsset[0])
+ if (release?.Assets == null)
+ yield break;
+
+ foreach (GitAsset asset in release.Assets)
{
- // parse version
- Match versionMatch = Regex.Match(asset.FileName, @"SMAPI-([\d\.]+(?:-.+)?)-installer.zip");
- if (!versionMatch.Success || !SemanticVersionImpl.TryParse(versionMatch.Groups[1].Value, out SemanticVersionImpl version))
+ Match match = Regex.Match(asset.FileName, @"SMAPI-(?<version>[\d\.]+(?:-.+)?)-installer(?<forDevs>-for-developers)?.zip");
+ if (!match.Success || !SemanticVersion.TryParse(match.Groups["version"].Value, out ISemanticVersion version))
continue;
+ bool isBeta = version.IsPrerelease();
+ bool isForDevs = match.Groups["forDevs"].Success;
- // save latest version
- if (latestVersion == null || latestVersion.CompareTo(version) < 0)
- {
- latestVersion = version;
- latestUrl = asset.DownloadUrl;
- }
+ yield return new ReleaseVersion(release, asset, version, isBeta, isForDevs);
}
+ }
- // return if prerelease
- return latestVersion?.Tag != null
- ? new IndexVersionModel(latestVersion.ToString(), release.Body, latestUrl, null)
- : null;
+ /// <summary>A parsed release download.</summary>
+ private class ReleaseVersion
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The underlying GitHub release.</summary>
+ public GitRelease Release { get; }
+
+ /// <summary>The underlying download asset.</summary>
+ public GitAsset Asset { get; }
+
+ /// <summary>The SMAPI version.</summary>
+ public ISemanticVersion Version { get; }
+
+ /// <summary>Whether this is a beta download.</summary>
+ public bool IsBeta { get; }
+
+ /// <summary>Whether this is a 'for developers' download.</summary>
+ public bool IsForDevs { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="release">The underlying GitHub release.</param>
+ /// <param name="asset">The underlying download asset.</param>
+ /// <param name="version">The SMAPI version.</param>
+ /// <param name="isBeta">Whether this is a beta download.</param>
+ /// <param name="isForDevs">Whether this is a 'for developers' download.</param>
+ public ReleaseVersion(GitRelease release, GitAsset asset, ISemanticVersion version, bool isBeta, bool isForDevs)
+ {
+ this.Release = release;
+ this.Asset = asset;
+ this.Version = version;
+ this.IsBeta = isBeta;
+ this.IsForDevs = isForDevs;
+ }
}
}
}
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs
index 62547deb..17f8d3aa 100644
--- a/src/SMAPI.Web/Controllers/LogParserController.cs
+++ b/src/SMAPI.Web/Controllers/LogParserController.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.IO.Compression;
+using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
@@ -20,8 +21,8 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Properties
*********/
- /// <summary>The log parser config settings.</summary>
- private readonly ContextConfig Config;
+ /// <summary>The site config settings.</summary>
+ private readonly SiteConfig Config;
/// <summary>The underlying Pastebin client.</summary>
private readonly IPastebinClient Pastebin;
@@ -38,11 +39,11 @@ namespace StardewModdingAPI.Web.Controllers
** Constructor
***/
/// <summary>Construct an instance.</summary>
- /// <param name="contextProvider">The context config settings.</param>
+ /// <param name="siteConfig">The context config settings.</param>
/// <param name="pastebin">The Pastebin API client.</param>
- public LogParserController(IOptions<ContextConfig> contextProvider, IPastebinClient pastebin)
+ public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin)
{
- this.Config = contextProvider.Value;
+ this.Config = siteConfig.Value;
this.Pastebin = pastebin;
}
@@ -51,34 +52,49 @@ namespace StardewModdingAPI.Web.Controllers
***/
/// <summary>Render the log parser UI.</summary>
/// <param name="id">The paste ID.</param>
+ /// <param name="raw">Whether to display the raw unparsed log.</param>
[HttpGet]
[Route("log")]
[Route("log/{id}")]
- public async Task<ViewResult> Index(string id = null)
+ public async Task<ViewResult> Index(string id = null, bool raw = false)
{
// fresh page
if (string.IsNullOrWhiteSpace(id))
- return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, null));
+ return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id));
// log page
PasteInfo paste = await this.GetAsync(id);
ParsedLog log = paste.Success
? new LogParser().Parse(paste.Content)
: new ParsedLog { IsValid = false, Error = "Pastebin error: " + paste.Error };
- return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log));
+ return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log, raw));
}
/***
** JSON
***/
/// <summary>Save raw log data.</summary>
- /// <param name="content">The log content to save.</param>
- [HttpPost, Produces("application/json"), AllowLargePosts]
- [Route("log/save")]
- public async Task<SavePasteResult> PostAsync([FromBody] string content)
+ [HttpPost, AllowLargePosts]
+ [Route("log")]
+ public async Task<ActionResult> PostAsync()
{
- content = this.CompressString(content);
- return await this.Pastebin.PostAsync(content);
+ // get raw log text
+ string input = this.Request.Form["input"].FirstOrDefault();
+ if (string.IsNullOrWhiteSpace(input))
+ return this.View("Index", new LogParserModel(this.Config.LogParserUrl, null) { UploadError = "The log file seems to be empty." });
+
+ // upload log
+ input = this.CompressString(input);
+ SavePasteResult result = await this.Pastebin.PostAsync(input);
+
+ // handle errors
+ if (!result.Success)
+ return this.View("Index", new LogParserModel(this.Config.LogParserUrl, result.ID) { UploadError = $"Pastebin error: {result.Error ?? "unknown error"}" });
+
+ // redirect to view
+ UriBuilder uri = new UriBuilder(new Uri(this.Config.LogParserUrl));
+ uri.Path = uri.Path.TrimEnd('/') + '/' + result.ID;
+ return this.Redirect(uri.Uri.ToString());
}
@@ -115,7 +131,7 @@ namespace StardewModdingAPI.Web.Controllers
}
// prefix length
- var zipBuffer = new byte[compressedData.Length + 4];
+ byte[] zipBuffer = new byte[compressedData.Length + 4];
Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length);
Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4);
@@ -151,7 +167,7 @@ namespace StardewModdingAPI.Web.Controllers
memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4);
// read data
- var buffer = new byte[dataLength];
+ byte[] buffer = new byte[dataLength];
memoryStream.Position = 0;
using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
gZipStream.Read(buffer, 0, buffer.Length);
diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs
index 24517263..b500e19d 100644
--- a/src/SMAPI.Web/Controllers/ModsApiController.cs
+++ b/src/SMAPI.Web/Controllers/ModsApiController.cs
@@ -1,12 +1,17 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
+using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
-using StardewModdingAPI.Common.Models;
+using StardewModdingAPI.Toolkit;
+using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
+using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
+using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
@@ -38,19 +43,28 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>A regex which matches SMAPI-style semantic version.</summary>
private readonly string VersionRegex;
+ /// <summary>The internal mod metadata list.</summary>
+ private readonly ModDatabase ModDatabase;
+
+ /// <summary>The web URL for the wiki compatibility list.</summary>
+ private readonly string WikiCompatibilityPageUrl;
+
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
+ /// <param name="environment">The web hosting environment.</param>
/// <param name="cache">The cache in which to store mod metadata.</param>
/// <param name="configProvider">The config settings for mod update checks.</param>
/// <param name="chucklefish">The Chucklefish API client.</param>
/// <param name="github">The GitHub API client.</param>
/// <param name="nexus">The Nexus API client.</param>
- public ModsApiController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus)
+ public ModsApiController(IHostingEnvironment environment, IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus)
{
+ this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "StardewModdingAPI.metadata.json"));
ModUpdateCheckConfig config = configProvider.Value;
+ this.WikiCompatibilityPageUrl = config.WikiCompatibilityPageUrl;
this.Cache = cache;
this.SuccessCacheMinutes = config.SuccessCacheMinutes;
@@ -67,76 +81,126 @@ namespace StardewModdingAPI.Web.Controllers
}
/// <summary>Fetch version metadata for the given mods.</summary>
- /// <param name="modKeys">The namespaced mod keys to search as a comma-delimited array.</param>
- /// <param name="allowInvalidVersions">Whether to allow non-semantic versions, instead of returning an error for those.</param>
- [HttpGet]
- public async Task<IDictionary<string, ModInfoModel>> GetAsync(string modKeys, bool allowInvalidVersions = false)
+ /// <param name="model">The mod search criteria.</param>
+ [HttpPost]
+ public async Task<object> PostAsync([FromBody] ModSearchModel model)
{
- string[] modKeysArray = modKeys?.Split(',').ToArray();
- if (modKeysArray == null || !modKeysArray.Any())
- return new Dictionary<string, ModInfoModel>();
+ // parse request data
+ ISemanticVersion apiVersion = this.GetApiVersion();
+ ModSearchEntryModel[] searchMods = this.GetSearchMods(model, apiVersion).ToArray();
+
+ // fetch wiki data
+ WikiCompatibilityEntry[] wikiData = await this.GetWikiDataAsync();
+
+ // fetch data
+ IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase);
+ foreach (ModSearchEntryModel mod in searchMods)
+ {
+ if (string.IsNullOrWhiteSpace(mod.ID))
+ continue;
+
+ ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata);
+ result.SetBackwardsCompatibility(apiVersion);
+ mods[mod.ID] = result;
+ }
- return await this.PostAsync(new ModSearchModel(modKeysArray, allowInvalidVersions));
+ // return in expected structure
+ return apiVersion.IsNewerThan("2.6-beta.18")
+ ? mods.Values
+ : (object)mods;
}
- /// <summary>Fetch version metadata for the given mods.</summary>
- /// <param name="search">The mod search criteria.</param>
- [HttpPost]
- public async Task<IDictionary<string, ModInfoModel>> PostAsync([FromBody] ModSearchModel search)
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the metadata for a mod.</summary>
+ /// <param name="search">The mod data to match.</param>
+ /// <param name="wikiData">The wiki data.</param>
+ /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param>
+ /// <returns>Returns the mod data if found, else <c>null</c>.</returns>
+ private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiCompatibilityEntry[] wikiData, bool includeExtendedMetadata)
{
- // parse model
- bool allowInvalidVersions = search?.AllowInvalidVersions ?? false;
- string[] modKeys = (search?.ModKeys?.ToArray() ?? new string[0])
- .Distinct(StringComparer.CurrentCultureIgnoreCase)
- .OrderBy(p => p, StringComparer.CurrentCultureIgnoreCase)
- .ToArray();
+ // resolve update keys
+ var updateKeys = new HashSet<string>(search.UpdateKeys ?? new string[0], StringComparer.InvariantCultureIgnoreCase);
+ ModDataRecord record = this.ModDatabase.Get(search.ID);
+ if (record?.Fields != null)
+ {
+ string defaultUpdateKey = record.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value;
+ if (!string.IsNullOrWhiteSpace(defaultUpdateKey))
+ updateKeys.Add(defaultUpdateKey);
+ }
- // fetch mod info
- IDictionary<string, ModInfoModel> result = new Dictionary<string, ModInfoModel>(StringComparer.CurrentCultureIgnoreCase);
- foreach (string modKey in modKeys)
+ // get latest versions
+ ModEntryModel result = new ModEntryModel { ID = search.ID };
+ IList<string> errors = new List<string>();
+ foreach (string updateKey in updateKeys)
{
- // parse mod key
- if (!this.TryParseModKey(modKey, out string vendorKey, out string modID))
+ // fetch data
+ ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey);
+ if (data.Error != null)
{
- result[modKey] = new ModInfoModel("The mod key isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'.");
+ errors.Add(data.Error);
continue;
}
- // get matching repository
- if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository))
+ // handle main version
+ if (data.Version != null)
{
- result[modKey] = new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}].");
- continue;
+ if (!SemanticVersion.TryParse(data.Version, out ISemanticVersion version))
+ {
+ errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'.");
+ continue;
+ }
+
+ if (this.IsNewer(version, result.Main?.Version))
+ result.Main = new ModEntryVersionModel(version, data.Url);
}
- // fetch mod info
- result[modKey] = await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry =>
+ // handle optional version
+ if (data.PreviewVersion != null)
{
- // fetch info
- ModInfoModel info = await repository.GetModInfoAsync(modID);
-
- // validate
- if (info.Error == null)
+ if (!SemanticVersion.TryParse(data.PreviewVersion, out ISemanticVersion version))
{
- if (info.Version == null)
- info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: "Mod has no version number.");
- if (!allowInvalidVersions && !Regex.IsMatch(info.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase))
- info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: $"Mod has invalid semantic version '{info.Version}'.");
+ errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'.");
+ continue;
}
- // cache & return
- entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(info.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes);
- return info;
- });
+ if (this.IsNewer(version, result.Optional?.Version))
+ result.Optional = new ModEntryVersionModel(version, data.Url);
+ }
}
+ // get unofficial version
+ WikiCompatibilityEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(result.ID.Trim(), StringComparer.InvariantCultureIgnoreCase));
+ if (wikiEntry?.UnofficialVersion != null && this.IsNewer(wikiEntry.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.UnofficialVersion, result.Optional?.Version))
+ result.Unofficial = new ModEntryVersionModel(wikiEntry.UnofficialVersion, this.WikiCompatibilityPageUrl);
+
+ // fallback to preview if latest is invalid
+ if (result.Main == null && result.Optional != null)
+ {
+ result.Main = result.Optional;
+ result.Optional = null;
+ }
+
+ // special cases
+ if (result.ID == "Pathoschild.SMAPI")
+ {
+ if (result.Main != null)
+ result.Main.Url = "https://smapi.io/";
+ if (result.Optional != null)
+ result.Optional.Url = "https://smapi.io/";
+ }
+
+ // add extended metadata
+ if (includeExtendedMetadata && (wikiEntry != null || record != null))
+ result.Metadata = new ModExtendedMetadataModel(wikiEntry, record);
+
+ // add result
+ result.Errors = errors.ToArray();
return result;
}
-
- /*********
- ** Private methods
- *********/
/// <summary>Parse a namespaced mod ID.</summary>
/// <param name="raw">The raw mod ID to parse.</param>
/// <param name="vendorKey">The parsed vendor key.</param>
@@ -158,5 +222,91 @@ namespace StardewModdingAPI.Web.Controllers
modID = parts[1].Trim();
return true;
}
+
+ /// <summary>Get whether a <paramref name="current"/> version is newer than an <paramref name="other"/> version.</summary>
+ /// <param name="current">The current version.</param>
+ /// <param name="other">The other version.</param>
+ private bool IsNewer(ISemanticVersion current, ISemanticVersion other)
+ {
+ return current != null && (other == null || other.IsOlderThan(current));
+ }
+
+ /// <summary>Get the mods for which the API should return data.</summary>
+ /// <param name="model">The search model.</param>
+ /// <param name="apiVersion">The requested API version.</param>
+ private IEnumerable<ModSearchEntryModel> GetSearchMods(ModSearchModel model, ISemanticVersion apiVersion)
+ {
+ if (model == null)
+ yield break;
+
+ // yield standard entries
+ if (model.Mods != null)
+ {
+ foreach (ModSearchEntryModel mod in model.Mods)
+ yield return mod;
+ }
+
+ // yield mod update keys if backwards compatible
+ if (model.ModKeys != null && model.ModKeys.Any() && !apiVersion.IsNewerThan("2.6-beta.17"))
+ {
+ foreach (string updateKey in model.ModKeys.Distinct())
+ yield return new ModSearchEntryModel(updateKey, new[] { updateKey });
+ }
+ }
+
+ /// <summary>Get mod data from the wiki compatibility list.</summary>
+ private async Task<WikiCompatibilityEntry[]> GetWikiDataAsync()
+ {
+ ModToolkit toolkit = new ModToolkit();
+ return await this.Cache.GetOrCreateAsync($"_wiki", async entry =>
+ {
+ try
+ {
+ WikiCompatibilityEntry[] entries = await toolkit.GetWikiCompatibilityListAsync();
+ entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.SuccessCacheMinutes);
+ return entries;
+ }
+ catch
+ {
+ entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.ErrorCacheMinutes);
+ return new WikiCompatibilityEntry[0];
+ }
+ });
+ }
+
+ /// <summary>Get the mod info for an update key.</summary>
+ /// <param name="updateKey">The namespaced update key.</param>
+ private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(string updateKey)
+ {
+ // parse update key
+ if (!this.TryParseModKey(updateKey, out string vendorKey, out string modID))
+ return new ModInfoModel($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'.");
+
+ // get matching repository
+ if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository))
+ return new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}].");
+
+ // fetch mod info
+ return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry =>
+ {
+ ModInfoModel result = await repository.GetModInfoAsync(modID);
+ if (result.Error != null)
+ {
+ if (result.Version == null)
+ result.Error = $"The update key '{updateKey}' matches a mod with no version number.";
+ else if (!Regex.IsMatch(result.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase))
+ result.Error = $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'.";
+ }
+ entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(result.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes);
+ return result;
+ });
+ }
+
+ /// <summary>Get the requested API version.</summary>
+ private ISemanticVersion GetApiVersion()
+ {
+ string actualVersion = (string)this.RouteData.Values["version"];
+ return new SemanticVersion(actualVersion);
+ }
}
}