From 0b5fa6bf86d08cd2205dca3279d99795e68f5a12 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 May 2020 21:30:28 -0400 Subject: add new 'unknown' compatibility status (#711) --- .../Framework/Clients/Wiki/WikiCompatibilityStatus.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) (limited to 'src/SMAPI.Toolkit/Framework') diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs index a1d2dfae..5cdf489f 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityStatus.cs @@ -3,25 +3,28 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// The compatibility status for a mod. public enum WikiCompatibilityStatus { + /// The status is unknown. + Unknown, + /// The mod is compatible. - Ok = 0, + Ok, /// The mod is compatible if you use an optional official download. - Optional = 1, + Optional, /// The mod is compatible if you use an unofficial update. - Unofficial = 2, + Unofficial, /// The mod isn't compatible, but the player can fix it or there's a good alternative. - Workaround = 3, + Workaround, /// The mod isn't compatible. - Broken = 4, + Broken, /// The mod is no longer maintained by the author, and an unofficial update or continuation is unlikely. - Abandoned = 5, + Abandoned, /// The mod is no longer needed and should be removed. - Obsolete = 6 + Obsolete } } -- cgit From a090b6c21c877e8835f25e1d70d667abf07d1d3c Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 16 May 2020 11:29:40 -0400 Subject: use newer C# features --- src/SMAPI.Installer/InteractiveInstaller.cs | 2 +- src/SMAPI.ModBuildConfig/DeployModTask.cs | 27 +++++++++--------- .../Framework/Clients/WebApi/WebApiClient.cs | 17 ++++++------ .../Framework/GameScanning/GameScanner.cs | 4 +-- src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs | 5 +--- .../Controllers/JsonValidatorController.cs | 32 ++++++++++------------ src/SMAPI.Web/Controllers/ModsApiController.cs | 4 +-- .../Clients/CurseForge/CurseForgeClient.cs | 3 +- src/SMAPI.Web/Framework/Compression/GzipHelper.cs | 2 +- src/SMAPI.Web/Startup.cs | 8 +++--- src/SMAPI/Framework/Input/GamePadStateBuilder.cs | 17 +++++------- src/SMAPI/Framework/Input/MouseStateBuilder.cs | 23 +++++++--------- src/SMAPI/Framework/ModLoading/AssemblyLoader.cs | 10 +++---- src/SMAPI/Framework/Networking/SGalaxyNetServer.cs | 29 ++++++++++---------- src/SMAPI/Framework/Networking/SLidgrenServer.cs | 31 ++++++++++----------- .../PerformanceMonitoring/AlertContext.cs | 2 +- .../Framework/PerformanceMonitoring/AlertEntry.cs | 2 +- .../Framework/PerformanceMonitoring/PeakEntry.cs | 2 +- .../PerformanceCounterEntry.cs | 2 +- .../Framework/Serialization/ColorConverter.cs | 2 +- .../Framework/Serialization/PointConverter.cs | 2 +- .../Framework/Serialization/RectangleConverter.cs | 2 +- .../Framework/Serialization/Vector2Converter.cs | 2 +- 23 files changed, 105 insertions(+), 125 deletions(-) (limited to 'src/SMAPI.Toolkit/Framework') diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 5b0c6e1f..f8371d57 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -707,7 +707,7 @@ namespace StardewModdingApi.Installer /// The indentation to prefix to output. private string InteractivelyChoose(string message, string[] options, string indent = "", Action print = null) { - print = print ?? this.PrintInfo; + print ??= this.PrintInfo; while (true) { diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 96d95e06..ced05a28 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -153,23 +153,22 @@ namespace StardewModdingAPI.ModBuildConfig // create zip file Directory.CreateDirectory(outputFolderPath); - using (Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write)) - using (ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create)) + using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write); + using ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create); + + foreach (var fileEntry in files) { - foreach (var fileEntry in files) - { - string relativePath = fileEntry.Key; - FileInfo file = fileEntry.Value; + string relativePath = fileEntry.Key; + FileInfo file = fileEntry.Value; - // get file info - string filePath = file.FullName; - string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/'); + // get file info + string filePath = file.FullName; + string entryName = folderName + '/' + relativePath.Replace(Path.DirectorySeparatorChar, '/'); - // add to zip - using (Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) - using (Stream fileStreamInZip = archive.CreateEntry(entryName).Open()) - fileStream.CopyTo(fileStreamInZip); - } + // add to zip + using Stream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + using Stream fileStreamInZip = archive.CreateEntry(entryName).Open(); + fileStream.CopyTo(fileStreamInZip); } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs index f0a7c82a..2fb6ed20 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs @@ -62,16 +62,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi private TResult Post(string url, TBody content) { // note: avoid HttpClient for Mac compatibility - using (WebClient client = new WebClient()) - { - Uri fullUrl = new Uri(this.BaseUrl, url); - string data = JsonConvert.SerializeObject(content); + using WebClient client = new WebClient(); - client.Headers["Content-Type"] = "application/json"; - client.Headers["User-Agent"] = $"SMAPI/{this.Version}"; - string response = client.UploadString(fullUrl, data); - return JsonConvert.DeserializeObject(response, this.JsonSettings); - } + Uri fullUrl = new Uri(this.BaseUrl, url); + string data = JsonConvert.SerializeObject(content); + + client.Headers["Content-Type"] = "application/json"; + client.Headers["User-Agent"] = $"SMAPI/{this.Version}"; + string response = client.UploadString(fullUrl, data); + return JsonConvert.DeserializeObject(response, this.JsonSettings); } } } diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs index 212c70ef..4eec3424 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -124,8 +124,8 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning XElement root; try { - using (FileStream stream = file.OpenRead()) - root = XElement.Load(stream); + using FileStream stream = file.OpenRead(); + root = XElement.Load(stream); } catch { diff --git a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs index c45448f3..1e490448 100644 --- a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs @@ -30,10 +30,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// Detect the current OS. public static Platform DetectPlatform() { - if (EnvironmentUtility.CachedPlatform == null) - EnvironmentUtility.CachedPlatform = EnvironmentUtility.DetectPlatformImpl(); - - return EnvironmentUtility.CachedPlatform.Value; + return EnvironmentUtility.CachedPlatform ??= EnvironmentUtility.DetectPlatformImpl(); } diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs index 2ade3e3d..c43fb929 100644 --- a/src/SMAPI.Web/Controllers/JsonValidatorController.cs +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -275,21 +275,20 @@ namespace StardewModdingAPI.Web.Controllers errors = new Dictionary(errors, StringComparer.InvariantCultureIgnoreCase); // match error by type and message - foreach (var pair in errors) + foreach ((string target, string errorMessage) in errors) { - if (!pair.Key.Contains(":")) + if (!target.Contains(":")) continue; - string[] parts = pair.Key.Split(':', 2); + string[] parts = target.Split(':', 2); if (parts[0].Equals(error.ErrorType.ToString(), StringComparison.InvariantCultureIgnoreCase) && Regex.IsMatch(error.Message, parts[1])) - return pair.Value?.Trim(); + return errorMessage?.Trim(); } // match by type - if (errors.TryGetValue(error.ErrorType.ToString(), out string message)) - return message?.Trim(); - - return null; + return errors.TryGetValue(error.ErrorType.ToString(), out string message) + ? message?.Trim() + : null; } return GetRawOverrideError() @@ -304,10 +303,10 @@ namespace StardewModdingAPI.Web.Controllers { if (schema.ExtensionData != null) { - foreach (var pair in schema.ExtensionData) + foreach ((string curKey, JToken value) in schema.ExtensionData) { - if (pair.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)) - return pair.Value.ToObject(); + if (curKey.Equals(key, StringComparison.InvariantCultureIgnoreCase)) + return value.ToObject(); } } @@ -318,14 +317,11 @@ namespace StardewModdingAPI.Web.Controllers /// The value to format. private string FormatValue(object value) { - switch (value) + return value switch { - case List list: - return string.Join(", ", list); - - default: - return value?.ToString() ?? "null"; - } + List list => string.Join(", ", list), + _ => value?.ToString() ?? "null" + }; } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 06768f03..e841a075 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -388,9 +388,9 @@ namespace StardewModdingAPI.Web.Controllers if (map.ContainsKey(parsed.ToString())) return map[parsed.ToString()]; - foreach (var pair in map) + foreach ((string fromRaw, string toRaw) in map) { - if (SemanticVersion.TryParse(pair.Key, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(pair.Value, allowNonStandard, out ISemanticVersion newVersion)) + if (SemanticVersion.TryParse(fromRaw, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(toRaw, allowNonStandard, out ISemanticVersion newVersion)) return newVersion.ToString(); } } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs index 140b854e..a6fd21fd 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -57,8 +57,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge if (!SemanticVersion.TryParse(raw, out version)) { - if (invalidVersion == null) - invalidVersion = raw; + invalidVersion ??= raw; continue; } } diff --git a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs index cc8f4737..676d660d 100644 --- a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs +++ b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs @@ -69,7 +69,7 @@ namespace StardewModdingAPI.Web.Framework.Compression return rawText; // decompress - using (MemoryStream memoryStream = new MemoryStream()) + using MemoryStream memoryStream = new MemoryStream(); { // read length prefix int dataLength = BitConverter.ToInt32(zipBuffer, 0); diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 07869797..35d22459 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -83,7 +83,7 @@ namespace StardewModdingAPI.Web .AddRazorPages(); // init MongoDB - services.AddSingleton(serv => !mongoConfig.IsConfigured() + services.AddSingleton(_ => !mongoConfig.IsConfigured() ? MongoDbRunner.Start() : throw new InvalidOperationException("The MongoDB connection is configured, so the local development version should not be used.") ); @@ -265,10 +265,10 @@ namespace StardewModdingAPI.Web ["Modding:Object_data"] = new[] { "^/for-devs/object-data", "^/guides/object-data" }, ["Modding:Weather_data"] = new[] { "^/for-devs/weather", "^/guides/weather" } }; - foreach (KeyValuePair pair in wikiRedirects) + foreach ((string page, string[] patterns) in wikiRedirects) { - foreach (string pattern in pair.Value) - redirects.Add(new RedirectToUrlRule(pattern, "https://stardewvalleywiki.com/" + pair.Key)); + foreach (string pattern in patterns) + redirects.Add(new RedirectToUrlRule(pattern, "https://stardewvalleywiki.com/" + page)); } return redirects; diff --git a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs index 36622066..2657fd12 100644 --- a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs +++ b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs @@ -205,16 +205,13 @@ namespace StardewModdingAPI.Framework.Input /// Get the equivalent state. public GamePadState GetState() { - if (this.State == null) - { - this.State = new GamePadState( - leftThumbStick: this.LeftStickPos, - rightThumbStick: this.RightStickPos, - leftTrigger: this.LeftTrigger, - rightTrigger: this.RightTrigger, - buttons: this.GetButtonBitmask() // MonoGame requires one bitmask here; don't specify multiple values - ); - } + this.State ??= new GamePadState( + leftThumbStick: this.LeftStickPos, + rightThumbStick: this.RightStickPos, + leftTrigger: this.LeftTrigger, + rightTrigger: this.RightTrigger, + buttons: this.GetButtonBitmask() // MonoGame requires one bitmask here; don't specify multiple values + ); return this.State.Value; } diff --git a/src/SMAPI/Framework/Input/MouseStateBuilder.cs b/src/SMAPI/Framework/Input/MouseStateBuilder.cs index 59956feb..1cc16ca9 100644 --- a/src/SMAPI/Framework/Input/MouseStateBuilder.cs +++ b/src/SMAPI/Framework/Input/MouseStateBuilder.cs @@ -89,19 +89,16 @@ namespace StardewModdingAPI.Framework.Input /// Get the equivalent state. public MouseState GetState() { - if (this.State == null) - { - this.State = new MouseState( - x: this.X, - y: this.Y, - scrollWheel: this.ScrollWheelValue, - leftButton: this.ButtonStates[SButton.MouseLeft], - middleButton: this.ButtonStates[SButton.MouseMiddle], - rightButton: this.ButtonStates[SButton.MouseRight], - xButton1: this.ButtonStates[SButton.MouseX1], - xButton2: this.ButtonStates[SButton.MouseX2] - ); - } + this.State ??= new MouseState( + x: this.X, + y: this.Y, + scrollWheel: this.ScrollWheelValue, + leftButton: this.ButtonStates[SButton.MouseLeft], + middleButton: this.ButtonStates[SButton.MouseMiddle], + rightButton: this.ButtonStates[SButton.MouseRight], + xButton1: this.ButtonStates[SButton.MouseX1], + xButton2: this.ButtonStates[SButton.MouseX2] + ); return this.State.Value; } diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index b5533335..8df492eb 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -125,12 +125,10 @@ namespace StardewModdingAPI.Framework.ModLoading { if (!oneAssembly) this.Monitor.Log($" Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace); - using (MemoryStream outStream = new MemoryStream()) - { - assembly.Definition.Write(outStream); - byte[] bytes = outStream.ToArray(); - lastAssembly = Assembly.Load(bytes); - } + using MemoryStream outStream = new MemoryStream(); + assembly.Definition.Write(outStream); + byte[] bytes = outStream.ToArray(); + lastAssembly = Assembly.Load(bytes); } else { diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs index 7dbfa767..ac9cf313 100644 --- a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs +++ b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs @@ -45,23 +45,22 @@ namespace StardewModdingAPI.Framework.Networking [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] protected override void onReceiveMessage(GalaxyID peer, Stream messageStream) { - using (IncomingMessage message = new IncomingMessage()) - using (BinaryReader reader = new BinaryReader(messageStream)) + using IncomingMessage message = new IncomingMessage(); + using BinaryReader reader = new BinaryReader(messageStream); + + message.Read(reader); + ulong peerID = peer.ToUint64(); // note: GalaxyID instances get reused, so need to store the underlying ID instead + this.OnProcessingMessage(message, outgoing => this.SendMessageToPeerID(peerID, outgoing), () => { - message.Read(reader); - ulong peerID = peer.ToUint64(); // note: GalaxyID instances get reused, so need to store the underlying ID instead - this.OnProcessingMessage(message, outgoing => this.SendMessageToPeerID(peerID, outgoing), () => + if (this.peers.ContainsLeft(message.FarmerID) && (long)this.peers[message.FarmerID] == (long)peerID) + this.gameServer.processIncomingMessage(message); + else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) { - if (this.peers.ContainsLeft(message.FarmerID) && (long)this.peers[message.FarmerID] == (long)peerID) - this.gameServer.processIncomingMessage(message); - else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) - { - NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); - GalaxyID capturedPeer = new GalaxyID(peerID); - this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), this.getConnectionId(peer), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); - } - }); - } + NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); + GalaxyID capturedPeer = new GalaxyID(peerID); + this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), this.getConnectionId(peer), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); + } + }); } /// Send a message to a remote peer. diff --git a/src/SMAPI/Framework/Networking/SLidgrenServer.cs b/src/SMAPI/Framework/Networking/SLidgrenServer.cs index f2c61917..05c8b872 100644 --- a/src/SMAPI/Framework/Networking/SLidgrenServer.cs +++ b/src/SMAPI/Framework/Networking/SLidgrenServer.cs @@ -44,25 +44,24 @@ namespace StardewModdingAPI.Framework.Networking { // add hook to call multiplayer core NetConnection peer = rawMessage.SenderConnection; - using (IncomingMessage message = new IncomingMessage()) - using (Stream readStream = new NetBufferReadStream(rawMessage)) - using (BinaryReader reader = new BinaryReader(readStream)) + using IncomingMessage message = new IncomingMessage(); + using Stream readStream = new NetBufferReadStream(rawMessage); + using BinaryReader reader = new BinaryReader(readStream); + + while (rawMessage.LengthBits - rawMessage.Position >= 8) { - while (rawMessage.LengthBits - rawMessage.Position >= 8) + message.Read(reader); + NetConnection connection = rawMessage.SenderConnection; // don't pass rawMessage into context because it gets reused + this.OnProcessingMessage(message, outgoing => this.sendMessage(connection, outgoing), () => { - message.Read(reader); - NetConnection connection = rawMessage.SenderConnection; // don't pass rawMessage into context because it gets reused - this.OnProcessingMessage(message, outgoing => this.sendMessage(connection, outgoing), () => + if (this.peers.ContainsLeft(message.FarmerID) && this.peers[message.FarmerID] == peer) + this.gameServer.processIncomingMessage(message); + else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) { - if (this.peers.ContainsLeft(message.FarmerID) && this.peers[message.FarmerID] == peer) - this.gameServer.processIncomingMessage(message); - else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) - { - NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); - this.gameServer.checkFarmhandRequest("", this.getConnectionId(rawMessage.SenderConnection), farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); - } - }); - } + NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); + this.gameServer.checkFarmhandRequest("", this.getConnectionId(rawMessage.SenderConnection), farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); + } + }); } } } diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs index 01197f74..af630055 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertContext.cs @@ -1,7 +1,7 @@ namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// The context for an alert. - internal struct AlertContext + internal readonly struct AlertContext { /********* ** Accessors diff --git a/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs index f5b80189..d5a0b343 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/AlertEntry.cs @@ -1,7 +1,7 @@ namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// A single alert entry. - internal struct AlertEntry + internal readonly struct AlertEntry { /********* ** Accessors diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs index cff502ad..1746e358 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/PeakEntry.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// A peak invocation time. - internal struct PeakEntry + internal readonly struct PeakEntry { /********* ** Accessors diff --git a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs index 8adbd88d..18cca628 100644 --- a/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs +++ b/src/SMAPI/Framework/PerformanceMonitoring/PerformanceCounterEntry.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Framework.PerformanceMonitoring { /// A single performance counter entry. - internal struct PerformanceCounterEntry + internal readonly struct PerformanceCounterEntry { /********* ** Accessors diff --git a/src/SMAPI/Framework/Serialization/ColorConverter.cs b/src/SMAPI/Framework/Serialization/ColorConverter.cs index 19979981..7315f1a5 100644 --- a/src/SMAPI/Framework/Serialization/ColorConverter.cs +++ b/src/SMAPI/Framework/Serialization/ColorConverter.cs @@ -35,7 +35,7 @@ namespace StardewModdingAPI.Framework.Serialization { string[] parts = str.Split(','); if (parts.Length != 4) - throw new SParseException($"Can't parse {typeof(Color).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Color)} from invalid value '{str}' (path: {path})."); int r = Convert.ToInt32(parts[0]); int g = Convert.ToInt32(parts[1]); diff --git a/src/SMAPI/Framework/Serialization/PointConverter.cs b/src/SMAPI/Framework/Serialization/PointConverter.cs index 3481c9b2..6cf795dc 100644 --- a/src/SMAPI/Framework/Serialization/PointConverter.cs +++ b/src/SMAPI/Framework/Serialization/PointConverter.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Framework.Serialization { string[] parts = str.Split(','); if (parts.Length != 2) - throw new SParseException($"Can't parse {typeof(Point).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Point)} from invalid value '{str}' (path: {path})."); int x = Convert.ToInt32(parts[0]); int y = Convert.ToInt32(parts[1]); diff --git a/src/SMAPI/Framework/Serialization/RectangleConverter.cs b/src/SMAPI/Framework/Serialization/RectangleConverter.cs index fbb2e253..a5780d8a 100644 --- a/src/SMAPI/Framework/Serialization/RectangleConverter.cs +++ b/src/SMAPI/Framework/Serialization/RectangleConverter.cs @@ -39,7 +39,7 @@ namespace StardewModdingAPI.Framework.Serialization var match = Regex.Match(str, @"^\{X:(?\d+) Y:(?\d+) Width:(?\d+) Height:(?\d+)\}$", RegexOptions.IgnoreCase); if (!match.Success) - throw new SParseException($"Can't parse {typeof(Rectangle).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Rectangle)} from invalid value '{str}' (path: {path})."); int x = Convert.ToInt32(match.Groups["x"].Value); int y = Convert.ToInt32(match.Groups["y"].Value); diff --git a/src/SMAPI/Framework/Serialization/Vector2Converter.cs b/src/SMAPI/Framework/Serialization/Vector2Converter.cs index 1d9b08e0..3e2ab776 100644 --- a/src/SMAPI/Framework/Serialization/Vector2Converter.cs +++ b/src/SMAPI/Framework/Serialization/Vector2Converter.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Framework.Serialization { string[] parts = str.Split(','); if (parts.Length != 2) - throw new SParseException($"Can't parse {typeof(Vector2).Name} from invalid value '{str}' (path: {path})."); + throw new SParseException($"Can't parse {nameof(Vector2)} from invalid value '{str}' (path: {path})."); float x = Convert.ToSingle(parts[0]); float y = Convert.ToSingle(parts[1]); -- cgit From 786077340f2cea37d82455fc413535ae82a912ee Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 23 May 2020 21:55:11 -0400 Subject: refactor update check API This simplifies the logic for individual clients, centralises common logic, and prepares for upcoming features. --- docs/release-notes.md | 1 + .../Framework/UpdateData/ModRepositoryKey.cs | 24 --- .../Framework/UpdateData/ModSiteKey.cs | 24 +++ .../Framework/UpdateData/UpdateKey.cs | 67 ++++---- src/SMAPI.Web/Controllers/ModsApiController.cs | 146 ++++------------- .../Framework/Caching/Mods/IModCacheRepository.cs | 6 +- .../Caching/Mods/ModCacheMemoryRepository.cs | 12 +- .../Clients/Chucklefish/ChucklefishClient.cs | 38 +++-- .../Clients/Chucklefish/ChucklefishMod.cs | 18 --- .../Clients/Chucklefish/IChucklefishClient.cs | 12 +- .../Clients/CurseForge/CurseForgeClient.cs | 72 ++++----- .../Framework/Clients/CurseForge/CurseForgeMod.cs | 23 --- .../Clients/CurseForge/ICurseForgeClient.cs | 12 +- .../Framework/Clients/GenericModDownload.cs | 36 +++++ src/SMAPI.Web/Framework/Clients/GenericModPage.cs | 79 +++++++++ .../Framework/Clients/GitHub/GitHubClient.cs | 56 +++++++ .../Framework/Clients/GitHub/IGitHubClient.cs | 2 +- src/SMAPI.Web/Framework/Clients/IModSiteClient.cs | 23 +++ .../Framework/Clients/ModDrop/IModDropClient.cs | 12 +- .../Framework/Clients/ModDrop/ModDropClient.cs | 63 ++++---- .../Framework/Clients/ModDrop/ModDropMod.cs | 21 --- .../ModDrop/ResponseModels/FileDataModel.cs | 16 +- .../Framework/Clients/Nexus/INexusClient.cs | 12 +- .../Framework/Clients/Nexus/NexusClient.cs | 94 ++++++----- src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs | 32 ---- .../Clients/Nexus/ResponseModels/NexusMod.cs | 33 ++++ src/SMAPI.Web/Framework/Extensions.cs | 6 + src/SMAPI.Web/Framework/IModDownload.cs | 15 ++ src/SMAPI.Web/Framework/IModPage.cs | 52 ++++++ src/SMAPI.Web/Framework/ModInfoModel.cs | 81 ++++++++++ .../Framework/ModRepositories/BaseRepository.cs | 51 ------ .../ModRepositories/ChucklefishRepository.cs | 57 ------- .../ModRepositories/CurseForgeRepository.cs | 63 -------- .../Framework/ModRepositories/GitHubRepository.cs | 82 ---------- .../Framework/ModRepositories/IModRepository.cs | 24 --- .../Framework/ModRepositories/ModDropRepository.cs | 57 ------- .../Framework/ModRepositories/ModInfoModel.cs | 96 ----------- .../Framework/ModRepositories/NexusRepository.cs | 65 -------- .../Framework/ModRepositories/RemoteModStatus.cs | 18 --- src/SMAPI.Web/Framework/ModSiteManager.cs | 180 +++++++++++++++++++++ src/SMAPI.Web/Framework/RemoteModStatus.cs | 18 +++ 41 files changed, 825 insertions(+), 974 deletions(-) delete mode 100644 src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs create mode 100644 src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs delete mode 100644 src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishMod.cs delete mode 100644 src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs create mode 100644 src/SMAPI.Web/Framework/Clients/GenericModDownload.cs create mode 100644 src/SMAPI.Web/Framework/Clients/GenericModPage.cs create mode 100644 src/SMAPI.Web/Framework/Clients/IModSiteClient.cs delete mode 100644 src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs delete mode 100644 src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs create mode 100644 src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs create mode 100644 src/SMAPI.Web/Framework/IModDownload.cs create mode 100644 src/SMAPI.Web/Framework/IModPage.cs create mode 100644 src/SMAPI.Web/Framework/ModInfoModel.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs delete mode 100644 src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs create mode 100644 src/SMAPI.Web/Framework/ModSiteManager.cs create mode 100644 src/SMAPI.Web/Framework/RemoteModStatus.cs (limited to 'src/SMAPI.Toolkit/Framework') diff --git a/docs/release-notes.md b/docs/release-notes.md index f3f2efa4..894fd562 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -24,6 +24,7 @@ * For SMAPI developers: * Eliminated MongoDB storage in the web services, which complicated the code unnecessarily. The app still uses an abstract interface for storage, so we can wrap a distributed cache in the future if needed. + * Overhauled update checks to simplify individual clients, centralize common logic, and enable upcoming features. * Merged the separate legacy redirects app on AWS into the main app on Azure. ## 3.5 diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs deleted file mode 100644 index 765ca334..00000000 --- a/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace StardewModdingAPI.Toolkit.Framework.UpdateData -{ - /// A mod repository which SMAPI can check for updates. - public enum ModRepositoryKey - { - /// An unknown or invalid mod repository. - Unknown, - - /// The Chucklefish mod repository. - Chucklefish, - - /// The CurseForge mod repository. - CurseForge, - - /// A GitHub project containing releases. - GitHub, - - /// The ModDrop mod repository. - ModDrop, - - /// The Nexus Mods mod repository. - Nexus - } -} diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs new file mode 100644 index 00000000..47cd3f7e --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/UpdateData/ModSiteKey.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Toolkit.Framework.UpdateData +{ + /// A mod site which SMAPI can check for updates. + public enum ModSiteKey + { + /// An unknown or invalid mod repository. + Unknown, + + /// The Chucklefish mod repository. + Chucklefish, + + /// The CurseForge mod repository. + CurseForge, + + /// A GitHub project containing releases. + GitHub, + + /// The ModDrop mod repository. + ModDrop, + + /// The Nexus Mods mod repository. + Nexus + } +} diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index 3fc1759e..f6044148 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -11,8 +11,8 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// The raw update key text. public string RawText { get; } - /// The mod repository containing the mod. - public ModRepositoryKey Repository { get; } + /// The mod site containing the mod. + public ModSiteKey Site { get; } /// The mod ID within the repository. public string ID { get; } @@ -26,53 +26,56 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData *********/ /// Construct an instance. /// The raw update key text. - /// The mod repository containing the mod. - /// The mod ID within the repository. - public UpdateKey(string rawText, ModRepositoryKey repository, string id) + /// The mod site containing the mod. + /// The mod ID within the site. + public UpdateKey(string rawText, ModSiteKey site, string id) { - this.RawText = rawText; - this.Repository = repository; - this.ID = id; + this.RawText = rawText?.Trim(); + this.Site = site; + this.ID = id?.Trim(); this.LooksValid = - repository != ModRepositoryKey.Unknown + site != ModSiteKey.Unknown && !string.IsNullOrWhiteSpace(id); } /// Construct an instance. - /// The mod repository containing the mod. - /// The mod ID within the repository. - public UpdateKey(ModRepositoryKey repository, string id) - : this($"{repository}:{id}", repository, id) { } + /// The mod site containing the mod. + /// The mod ID within the site. + public UpdateKey(ModSiteKey site, string id) + : this(UpdateKey.GetString(site, id), site, id) { } /// Parse a raw update key. /// The raw update key to parse. public static UpdateKey Parse(string raw) { - // split parts - string[] parts = raw?.Split(':'); - if (parts == null || parts.Length != 2) - return new UpdateKey(raw, ModRepositoryKey.Unknown, null); - - // extract parts - string repositoryKey = parts[0].Trim(); - string id = parts[1].Trim(); + // extract site + ID + string rawSite; + string id; + { + string[] parts = raw?.Trim().Split(':'); + if (parts == null || parts.Length != 2) + return new UpdateKey(raw, ModSiteKey.Unknown, null); + + rawSite = parts[0].Trim(); + id = parts[1].Trim(); + } if (string.IsNullOrWhiteSpace(id)) id = null; // parse - if (!Enum.TryParse(repositoryKey, true, out ModRepositoryKey repository)) - return new UpdateKey(raw, ModRepositoryKey.Unknown, id); + if (!Enum.TryParse(rawSite, true, out ModSiteKey site)) + return new UpdateKey(raw, ModSiteKey.Unknown, id); if (id == null) - return new UpdateKey(raw, repository, null); + return new UpdateKey(raw, site, null); - return new UpdateKey(raw, repository, id); + return new UpdateKey(raw, site, id); } /// Get a string that represents the current object. public override string ToString() { return this.LooksValid - ? $"{this.Repository}:{this.ID}" + ? UpdateKey.GetString(this.Site, this.ID) : this.RawText; } @@ -82,7 +85,7 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData { return other != null - && this.Repository == other.Repository + && this.Site == other.Site && string.Equals(this.ID, other.ID, StringComparison.InvariantCultureIgnoreCase); } @@ -97,7 +100,15 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// A hash code for the current object. public override int GetHashCode() { - return $"{this.Repository}:{this.ID}".ToLower().GetHashCode(); + return $"{this.Site}:{this.ID}".ToLower().GetHashCode(); + } + + /// Get the string representation of an update key. + /// The mod site containing the mod. + /// The mod ID within the repository. + public static string GetString(ModSiteKey site, string id) + { + return $"{site}:{id}".Trim(); } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index b9d7c32d..14be520d 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -15,13 +15,13 @@ using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.Caching; using StardewModdingAPI.Web.Framework.Caching.Mods; using StardewModdingAPI.Web.Framework.Caching.Wiki; +using StardewModdingAPI.Web.Framework.Clients; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; using StardewModdingAPI.Web.Framework.Clients.CurseForge; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.ConfigModels; -using StardewModdingAPI.Web.Framework.ModRepositories; namespace StardewModdingAPI.Web.Controllers { @@ -33,8 +33,8 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Fields *********/ - /// The mod repositories which provide mod metadata. - private readonly IDictionary Repositories; + /// The mod sites which provide mod metadata. + private readonly ModSiteManager ModSites; /// The cache in which to store wiki data. private readonly IWikiCacheRepository WikiCache; @@ -69,16 +69,7 @@ namespace StardewModdingAPI.Web.Controllers this.WikiCache = wikiCache; this.ModCache = modCache; this.Config = config; - this.Repositories = - new IModRepository[] - { - new ChucklefishRepository(chucklefish), - new CurseForgeRepository(curseForge), - new GitHubRepository(github), - new ModDropRepository(modDrop), - new NexusRepository(nexus) - } - .ToDictionary(p => p.VendorKey); + this.ModSites = new ModSiteManager(new IModSiteClient[] { chucklefish, curseForge, github, modDrop, nexus }); } /// Fetch version metadata for the given mods. @@ -149,40 +140,18 @@ namespace StardewModdingAPI.Web.Controllers } // fetch data - ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions); - if (data.Error != null) + ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.MapRemoteVersions); + if (data.Status != RemoteModStatus.Ok) { - errors.Add(data.Error); + errors.Add(data.Error ?? data.Status.ToString()); continue; } - // handle main version - if (data.Version != null) - { - ISemanticVersion version = this.GetMappedVersion(data.Version, wikiEntry?.MapRemoteVersions, allowNonStandardVersions); - if (version == null) - { - errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'."); - continue; - } - - if (this.IsNewer(version, main?.Version)) - main = new ModEntryVersionModel(version, data.Url); - } - - // handle optional version - if (data.PreviewVersion != null) - { - ISemanticVersion version = this.GetMappedVersion(data.PreviewVersion, wikiEntry?.MapRemoteVersions, allowNonStandardVersions); - if (version == null) - { - errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'."); - continue; - } - - if (this.IsNewer(version, optional?.Version)) - optional = new ModEntryVersionModel(version, data.Url); - } + // handle versions + if (this.IsNewer(data.Version, main?.Version)) + main = new ModEntryVersionModel(data.Version, data.Url); + if (this.IsNewer(data.PreviewVersion, optional?.Version)) + optional = new ModEntryVersionModel(data.PreviewVersion, data.Url); } // get unofficial version @@ -222,7 +191,7 @@ namespace StardewModdingAPI.Web.Controllers } // get recommended update (if any) - ISemanticVersion installedVersion = this.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions, allowNonStandard: allowNonStandardVersions); + ISemanticVersion installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions, allowNonStandard: allowNonStandardVersions); if (apiVersion != null && installedVersion != null) { // get newer versions @@ -282,32 +251,27 @@ namespace StardewModdingAPI.Web.Controllers /// Get the mod info for an update key. /// The namespaced update key. /// Whether to allow non-standard versions. - private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions) + /// Maps remote versions to a semantic version for update checks. + private async Task GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, IDictionary mapRemoteVersions) { - // get from cache - if (this.ModCache.TryGetMod(updateKey.Repository, updateKey.ID, out Cached cachedMod) && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes)) - return cachedMod.Data; - - // fetch from mod site + // get mod page + IModPage page; { - // get site - if (!this.Repositories.TryGetValue(updateKey.Repository, out IModRepository repository)) - return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); + bool isCached = + this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached cachedMod) + && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes); - // fetch mod - ModInfoModel mod = await repository.GetModInfoAsync(updateKey.ID); - if (mod.Error == null) + if (isCached) + page = cachedMod.Data; + else { - if (mod.Version == null) - mod.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number."); - else if (!SemanticVersion.TryParse(mod.Version, allowNonStandardVersions, out _)) - mod.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with invalid semantic version '{mod.Version}'."); + page = await this.ModSites.GetModPageAsync(updateKey); + this.ModCache.SaveMod(updateKey.Site, updateKey.ID, page); } - - // cache mod - this.ModCache.SaveMod(repository.VendorKey, updateKey.ID, mod); - return mod; } + + // get version info + return this.ModSites.GetPageVersions(page, allowNonStandardVersions, mapRemoteVersions); } /// Get update keys based on the available mod metadata, while maintaining the precedence order. @@ -334,13 +298,13 @@ namespace StardewModdingAPI.Web.Controllers if (entry != null) { if (entry.NexusID.HasValue) - yield return $"{ModRepositoryKey.Nexus}:{entry.NexusID}"; + yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID?.ToString()); if (entry.ModDropID.HasValue) - yield return $"{ModRepositoryKey.ModDrop}:{entry.ModDropID}"; + yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID?.ToString()); if (entry.CurseForgeID.HasValue) - yield return $"{ModRepositoryKey.CurseForge}:{entry.CurseForgeID}"; + yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID?.ToString()); if (entry.ChucklefishID.HasValue) - yield return $"{ModRepositoryKey.Chucklefish}:{entry.ChucklefishID}"; + yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID?.ToString()); } } @@ -355,51 +319,5 @@ namespace StardewModdingAPI.Web.Controllers yield return key; } } - - /// Get a semantic local version for update checks. - /// The version to parse. - /// A map of version replacements. - /// Whether to allow non-standard versions. - private ISemanticVersion GetMappedVersion(string version, IDictionary 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; - } - - /// Get a semantic local version for update checks. - /// The version to map. - /// A map of version replacements. - /// Whether to allow non-standard versions. - private string GetRawMappedVersion(string version, IDictionary 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; - } } } 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 /// The mod's unique ID within the . /// The fetched mod. /// Whether to update the mod's 'last requested' date. - bool TryGetMod(ModRepositoryKey site, string id, out Cached mod, bool markRequested = true); + bool TryGetMod(ModSiteKey site, string id, out Cached mod, bool markRequested = true); /// Save data fetched for a mod. /// The mod site on which the mod is found. /// The mod's unique ID within the . /// The mod data. - void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod); + void SaveMod(ModSiteKey site, string id, IModPage mod); /// Delete data for mods which haven't been requested within a given time limit. /// The minimum age for which to remove mods. 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 *********/ /// The cached mod data indexed by {site key}:{ID}. - private readonly IDictionary> Mods = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); + private readonly IDictionary> Mods = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); /********* @@ -24,7 +24,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// The mod's unique ID within the . /// The fetched mod. /// Whether to update the mod's 'last requested' date. - public bool TryGetMod(ModRepositoryKey site, string id, out Cached mod, bool markRequested = true) + public bool TryGetMod(ModSiteKey site, string id, out Cached 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 /// The mod site on which the mod is found. /// The mod's unique ID within the . /// The mod data. - 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(mod); + this.Mods[key] = new Cached(mod); } /// Delete data for mods which haven't been requested within a given time limit. @@ -73,7 +73,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// Get a cache key. /// The mod site. /// The mod ID. - 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 { @@ -19,6 +20,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish private readonly IClient Client; + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.Chucklefish; + + /********* ** Public methods *********/ @@ -32,42 +40,40 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); } - /// Get metadata about a mod. - /// The Chucklefish mod ID. - /// Returns the mod info if found, else null. - public async Task GetModAsync(uint id) + /// Get update check info about a mod. + /// The mod ID. + public async Task 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()); } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. 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 -{ - /// Mod metadata from the Chucklefish mod site. - internal class ChucklefishMod - { - /********* - ** Accessors - *********/ - /// The mod name. - public string Name { get; set; } - - /// The mod's semantic version number. - public string Version { get; set; } - - /// The mod's web URL. - 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 { /// An HTTP client for fetching mod metadata from the Chucklefish mod site. - internal interface IChucklefishClient : IDisposable - { - /********* - ** Methods - *********/ - /// Get metadata about a mod. - /// The Chucklefish mod ID. - /// Returns the mod info if found, else null. - Task 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 @@ -20,6 +20,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge private readonly Regex VersionInNamePattern = new Regex(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled); + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.CurseForge; + + /********* ** Public methods *********/ @@ -31,59 +38,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent); } - /// Get metadata about a mod. - /// The CurseForge mod ID. - /// Returns the mod info if found, else null. - public async Task GetModAsync(long id) + /// Get update check info about a mod. + /// The mod ID. + public async Task 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(); 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 downloads = new List(); 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); } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. 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 -{ - /// Mod metadata from the CurseForge API. - internal class CurseForgeMod - { - /********* - ** Accessors - *********/ - /// The mod name. - public string Name { get; set; } - - /// The latest file version. - public string LatestVersion { get; set; } - - /// The mod's web URL. - public string Url { get; set; } - - /// A user-friendly error which indicates why fetching the mod info failed (if applicable). - 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 { /// An HTTP client for fetching mod metadata from the CurseForge API. - internal interface ICurseForgeClient : IDisposable - { - /********* - ** Methods - *********/ - /// Get metadata about a mod. - /// The CurseForge mod ID. - /// Returns the mod info if found, else null. - Task 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 +{ + /// Generic metadata about a file download on a mod page. + internal class GenericModDownload : IModDownload + { + /********* + ** Accessors + *********/ + /// The download's display name. + public string Name { get; set; } + + /// The download's description. + public string Description { get; set; } + + /// The download's file version. + public string Version { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an empty instance. + public GenericModDownload() { } + + /// Construct an instance. + /// The download's display name. + /// The download's description. + /// The download's file version. + 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 +{ + /// Generic metadata about a mod page. + internal class GenericModPage : IModPage + { + /********* + ** Accessors + *********/ + /// The mod site containing the mod. + public ModSiteKey Site { get; set; } + + /// The mod's unique ID within the site. + public string Id { get; set; } + + /// The mod name. + public string Name { get; set; } + + /// The mod's semantic version number. + public string Version { get; set; } + + /// The mod's web URL. + public string Url { get; set; } + + /// The mod downloads. + public IModDownload[] Downloads { get; set; } = new IModDownload[0]; + + /// The mod availability status on the remote site. + public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + public string Error { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an empty instance. + public GenericModPage() { } + + /// Construct an instance. + /// The mod site containing the mod. + /// The mod's unique ID within the site. + public GenericModPage(ModSiteKey site, string id) + { + this.Site = site; + this.Id = id; + } + + /// Set the fetched mod info. + /// The mod name. + /// The mod's semantic version number. + /// The mod's web URL. + /// The mod downloads. + public IModPage SetInfo(string name, string version, string url, IEnumerable downloads) + { + this.Name = name; + this.Version = version; + this.Url = url; + this.Downloads = downloads.ToArray(); + + return this; + } + + /// Set a mod fetch error. + /// The mod availability status on the remote site. + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + 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 { @@ -16,6 +17,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub private readonly IClient Client; + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.GitHub; + + /********* ** Public methods *********/ @@ -79,6 +87,54 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub } } + /// Get update check info about a mod. + /// The mod ID. + public async Task 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); + } + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. 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 { /// An HTTP client for fetching metadata from GitHub. - 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 +{ + /// A client for fetching update check info from a mod site. + internal interface IModSiteClient + { + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey { get; } + + + /********* + ** Methods + *********/ + /// Get update check info about a mod. + /// The mod ID. + Task 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 { /// An HTTP client for fetching mod metadata from the ModDrop API. - internal interface IModDropClient : IDisposable - { - /********* - ** Methods - *********/ - /// Get metadata about a mod. - /// The ModDrop mod ID. - /// Returns the mod info if found, else null. - Task 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 @@ -18,6 +19,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop private readonly string ModUrlFormat; + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.ModDrop; + + /********* ** Public methods *********/ @@ -31,60 +39,45 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop this.ModUrlFormat = modUrlFormat; } - /// Get metadata about a mod. - /// The ModDrop mod ID. - /// Returns the mod info if found, else null. - public async Task GetModAsync(long id) + /// Get update check info about a mod. + /// The mod ID. + public async Task 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(); - 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(); 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); } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. 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 -{ - /// Mod metadata from the ModDrop API. - internal class ModDropMod - { - /********* - ** Accessors - *********/ - /// The mod name. - public string Name { get; set; } - - /// The latest default file version. - public ISemanticVersion LatestDefaultVersion { get; set; } - - /// The latest optional file version. - public ISemanticVersion LatestOptionalVersion { get; set; } - - /// The mod's web URL. - 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 { /// Metadata from the ModDrop API about a mod file. public class FileDataModel { + /// The file title. + [JsonProperty("title")] + public string Name { get; set; } + + /// The file description. + [JsonProperty("desc")] + public string Description { get; set; } + + /// The file version. + public string Version { get; set; } + /// Whether the file is deleted. public bool IsDeleted { get; set; } @@ -14,8 +27,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// Whether this is an archived file. public bool IsOld { get; set; } - - /// The file version. - 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 { /// An HTTP client for fetching mod metadata from Nexus Mods. - internal interface INexusClient : IDisposable - { - /********* - ** Methods - *********/ - /// Get metadata about a mod. - /// The Nexus mod ID. - /// Returns the mod info if found, else null. - Task 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 @@ -30,6 +32,13 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus private readonly FluentNexusClient ApiClient; + /********* + ** Accessors + *********/ + /// The unique key for the mod site. + public ModSiteKey SiteKey => ModSiteKey.Nexus; + + /********* ** Public methods *********/ @@ -48,20 +57,32 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion); } - /// Get metadata about a mod. - /// The Nexus mod ID. - /// Returns the mod info if found, else null. - public async Task GetModAsync(uint id) + /// Get update check info about a mod. + /// The mod ID. + public async Task 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; } /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. @@ -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 rawVersions = new List(); + // extract files + var downloads = new List(); 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
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/NexusMod.cs deleted file mode 100644 index 0f1b29d5..00000000 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Newtonsoft.Json; - -namespace StardewModdingAPI.Web.Framework.Clients.Nexus -{ - /// Mod metadata from Nexus Mods. - internal class NexusMod - { - /********* - ** Accessors - *********/ - /// The mod name. - public string Name { get; set; } - - /// The mod's semantic version number. - public string Version { get; set; } - - /// The latest file version. - public ISemanticVersion LatestFileVersion { get; set; } - - /// The mod's web URL. - [JsonProperty("mod_page_uri")] - public string Url { get; set; } - - /// The mod's publication status. - [JsonIgnore] - public NexusModStatus Status { get; set; } = NexusModStatus.Ok; - - /// A user-friendly error which indicates why fetching the mod info failed (if applicable). - [JsonIgnore] - public string Error { get; set; } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs new file mode 100644 index 00000000..aef90ede --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels +{ + /// Mod metadata from Nexus Mods. + internal class NexusMod + { + /********* + ** Accessors + *********/ + /// The mod name. + public string Name { get; set; } + + /// The mod's semantic version number. + public string Version { get; set; } + + /// The mod's web URL. + [JsonProperty("mod_page_uri")] + public string Url { get; set; } + + /// The mod's publication status. + [JsonIgnore] + public NexusModStatus Status { get; set; } = NexusModStatus.Ok; + + /// The files available to download. + [JsonIgnore] + public IModDownload[] Downloads { get; set; } + + /// A custom user-friendly error which indicates why fetching the mod info failed (if applicable). + [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 /// Provides extensions on ASP.NET Core types. public static class Extensions { + /********* + ** Public methods + *********/ + /**** + ** View helpers + ****/ /// Get a URL with the absolute path for an action method. Unlike , only the specified are added to the URL without merging values from the current HTTP request. /// The URL helper to extend. /// The name of the action method. 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 +{ + /// Generic metadata about a file download on a mod page. + internal interface IModDownload + { + /// The download's display name. + string Name { get; } + + /// The download's description. + string Description { get; } + + /// The download's file version. + 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 +{ + /// Generic metadata about a mod page. + internal interface IModPage + { + /********* + ** Accessors + *********/ + /// The mod site containing the mod. + ModSiteKey Site { get; } + + /// The mod's unique ID within the site. + string Id { get; } + + /// The mod name. + string Name { get; } + + /// The mod's semantic version number. + string Version { get; } + + /// The mod's web URL. + string Url { get; } + + /// The mod downloads. + IModDownload[] Downloads { get; } + + /// The mod page status. + RemoteModStatus Status { get; } + + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + string Error { get; } + + + /********* + ** Methods + *********/ + /// Set the fetched mod info. + /// The mod name. + /// The mod's semantic version number. + /// The mod's web URL. + /// The mod downloads. + IModPage SetInfo(string name, string version, string url, IEnumerable downloads); + + /// Set a mod fetch error. + /// The mod availability status on the remote site. + /// A user-friendly error which indicates why fetching the mod info failed (if applicable). + IModPage SetError(RemoteModStatus status, string error); + } +} diff --git a/src/SMAPI.Web/Framework/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs new file mode 100644 index 00000000..7845b8c5 --- /dev/null +++ b/src/SMAPI.Web/Framework/ModInfoModel.cs @@ -0,0 +1,81 @@ +using StardewModdingAPI.Web.Framework.Clients; + +namespace StardewModdingAPI.Web.Framework +{ + /// Generic metadata about a mod. + internal class ModInfoModel + { + /********* + ** Accessors + *********/ + /// The mod name. + public string Name { get; set; } + + /// The mod's latest version. + public ISemanticVersion Version { get; set; } + + /// The mod's latest optional or prerelease version, if newer than . + public ISemanticVersion PreviewVersion { get; set; } + + /// The mod's web URL. + public string Url { get; set; } + + /// The mod availability status on the remote site. + public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + + /// The error message indicating why the mod is invalid (if applicable). + public string Error { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an empty instance. + public ModInfoModel() { } + + /// Construct an instance. + /// The mod name. + /// The semantic version for the mod's latest release. + /// The semantic version for the mod's latest preview release, if available and different from . + /// The mod's web URL. + public ModInfoModel(string name, ISemanticVersion version, string url, ISemanticVersion previewVersion = null) + { + this + .SetBasicInfo(name, url) + .SetVersions(version, previewVersion); + } + + /// Set the basic mod info. + /// The mod name. + /// The mod's web URL. + public ModInfoModel SetBasicInfo(string name, string url) + { + this.Name = name; + this.Url = url; + + return this; + } + + /// Set the mod version info. + /// The semantic version for the mod's latest release. + /// The semantic version for the mod's latest preview release, if available and different from . + public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion previewVersion = null) + { + this.Version = version; + this.PreviewVersion = previewVersion; + + return this; + } + + /// Set a mod error. + /// The mod availability status on the remote site. + /// The error message indicating why the mod is invalid (if applicable). + public ModInfoModel SetError(RemoteModStatus status, string error) + { + this.Status = status; + this.Error = error; + + return this; + } + } +} 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 - *********/ - /// The unique key for this vendor. - public ModRepositoryKey VendorKey { get; } - - - /********* - ** Public methods - *********/ - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public abstract void Dispose(); - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public abstract Task GetModInfoAsync(string id); - - - /********* - ** Protected methods - *********/ - /// Construct an instance. - /// The unique key for this vendor. - protected RepositoryBase(ModRepositoryKey vendorKey) - { - this.VendorKey = vendorKey; - } - - /// Normalize a version string. - /// The version to normalize. - 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 -{ - /// An HTTP client for fetching mod metadata from the Chucklefish mod site. - internal class ChucklefishRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// The underlying HTTP client. - private readonly IChucklefishClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying HTTP client. - public ChucklefishRepository(IChucklefishClient client) - : base(ModRepositoryKey.Chucklefish) - { - this.Client = client; - } - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public override async Task 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()); - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - 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 -{ - /// An HTTP client for fetching mod metadata from CurseForge. - internal class CurseForgeRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// The underlying CurseForge API client. - private readonly ICurseForgeClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying CurseForge API client. - public CurseForgeRepository(ICurseForgeClient client) - : base(ModRepositoryKey.CurseForge) - { - this.Client = client; - } - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public override async Task 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()); - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - 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 -{ - /// An HTTP client for fetching mod metadata from GitHub project releases. - internal class GitHubRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// The underlying GitHub API client. - private readonly IGitHubClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying GitHub API client. - public GitHubRepository(IGitHubClient client) - : base(ModRepositoryKey.GitHub) - { - this.Client = client; - } - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public override async Task 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()); - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - 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 -{ - /// A repository which provides mod metadata. - internal interface IModRepository : IDisposable - { - /********* - ** Accessors - *********/ - /// The unique key for this vendor. - ModRepositoryKey VendorKey { get; } - - - /********* - ** Public methods - *********/ - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - Task 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 -{ - /// An HTTP client for fetching mod metadata from the ModDrop API. - internal class ModDropRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// The underlying ModDrop API client. - private readonly IModDropClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying Nexus Mods API client. - public ModDropRepository(IModDropClient client) - : base(ModRepositoryKey.ModDrop) - { - this.Client = client; - } - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public override async Task 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()); - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs deleted file mode 100644 index 46b98860..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs +++ /dev/null @@ -1,96 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// Generic metadata about a mod. - internal class ModInfoModel - { - /********* - ** Accessors - *********/ - /// The mod name. - public string Name { get; set; } - - /// The mod's latest version. - public string Version { get; set; } - - /// The mod's latest optional or prerelease version, if newer than . - public string PreviewVersion { get; set; } - - /// The mod's web URL. - public string Url { get; set; } - - /// The license URL, if available. - public string LicenseUrl { get; set; } - - /// The license name, if available. - public string LicenseName { get; set; } - - /// The mod availability status on the remote site. - public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; - - /// The error message indicating why the mod is invalid (if applicable). - public string Error { get; set; } - - - /********* - ** Public methods - *********/ - /// Construct an empty instance. - public ModInfoModel() { } - - /// Construct an instance. - /// The mod name. - /// The semantic version for the mod's latest release. - /// The semantic version for the mod's latest preview release, if available and different from . - /// The mod's web URL. - public ModInfoModel(string name, string version, string url, string previewVersion = null) - { - this - .SetBasicInfo(name, url) - .SetVersions(version, previewVersion); - } - - /// Set the basic mod info. - /// The mod name. - /// The mod's web URL. - public ModInfoModel SetBasicInfo(string name, string url) - { - this.Name = name; - this.Url = url; - - return this; - } - - /// Set the mod version info. - /// The semantic version for the mod's latest release. - /// The semantic version for the mod's latest preview release, if available and different from . - public ModInfoModel SetVersions(string version, string previewVersion = null) - { - this.Version = version; - this.PreviewVersion = previewVersion; - - return this; - } - - /// Set the license info, if available. - /// The license URL. - /// The license name. - public ModInfoModel SetLicense(string url, string name) - { - this.LicenseUrl = url; - this.LicenseName = name; - - return this; - } - - /// Set a mod error. - /// The mod availability status on the remote site. - /// The error message indicating why the mod is invalid (if applicable). - public ModInfoModel SetError(RemoteModStatus status, string error) - { - this.Status = status; - this.Error = error; - - return this; - } - } -} 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 -{ - /// An HTTP client for fetching mod metadata from Nexus Mods. - internal class NexusRepository : RepositoryBase - { - /********* - ** Fields - *********/ - /// The underlying Nexus Mods API client. - private readonly INexusClient Client; - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// The underlying Nexus Mods API client. - public NexusRepository(INexusClient client) - : base(ModRepositoryKey.Nexus) - { - this.Client = client; - } - - /// Get metadata about a mod in the repository. - /// The mod ID in this repository. - public override async Task 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()); - } - } - - /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. - public override void Dispose() - { - this.Client.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs b/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs deleted file mode 100644 index 02876556..00000000 --- a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace StardewModdingAPI.Web.Framework.ModRepositories -{ - /// The mod availability status on a remote site. - internal enum RemoteModStatus - { - /// The mod is valid. - Ok, - - /// The mod data was fetched, but the data is not valid (e.g. version isn't semantic). - InvalidData, - - /// The mod does not exist. - DoesNotExist, - - /// The mod was temporarily unavailable (e.g. the site could not be reached or an unknown error occurred). - TemporaryError - } -} 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 +{ + /// Handles fetching data from mod sites. + internal class ModSiteManager + { + /********* + ** Fields + *********/ + /// The mod sites which provide mod metadata. + private readonly IDictionary ModSites; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The mod sites which provide mod metadata. + public ModSiteManager(IModSiteClient[] modSites) + { + this.ModSites = modSites.ToDictionary(p => p.SiteKey); + } + + /// Get the mod info for an update key. + /// The namespaced update key. + public async Task 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}'."); + } + + /// Parse version info for the given mod page info. + /// The mod page info. + /// Maps remote versions to a semantic version for update checks. + /// Whether to allow non-standard versions. + public ModInfoModel GetPageVersions(IModPage page, bool allowNonStandardVersions, IDictionary 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); + } + + /// Get a semantic local version for update checks. + /// The version to parse. + /// A map of version replacements. + /// Whether to allow non-standard versions. + public ISemanticVersion GetMappedVersion(string version, IDictionary 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 + *********/ + /// Get the mod version numbers for the given mod. + /// The mod to check. + /// Whether to allow non-standard versions. + /// Maps remote versions to a semantic version for update checks. + /// The main mod version. + /// The latest prerelease version, if newer than . + private bool TryGetLatestVersions(IModPage mod, bool allowNonStandardVersions, IDictionary 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; + } + + /// Get a semantic local version for update checks. + /// The version to map. + /// A map of version replacements. + /// Whether to allow non-standard versions. + private string GetRawMappedVersion(string version, IDictionary 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; + } + + /// Normalize a version string. + /// The version to normalize. + 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/RemoteModStatus.cs b/src/SMAPI.Web/Framework/RemoteModStatus.cs new file mode 100644 index 00000000..139ecfd3 --- /dev/null +++ b/src/SMAPI.Web/Framework/RemoteModStatus.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework +{ + /// The mod availability status on a remote site. + internal enum RemoteModStatus + { + /// The mod is valid. + Ok, + + /// The mod data was fetched, but the data is not valid (e.g. version isn't semantic). + InvalidData, + + /// The mod does not exist. + DoesNotExist, + + /// The mod was temporarily unavailable (e.g. the site could not be reached or an unknown error occurred). + TemporaryError + } +} -- cgit From d97b11060c310f1fa6064f02496e6a6162a44573 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 24 May 2020 00:21:51 -0400 Subject: add update subkeys --- docs/release-notes.md | 1 + .../Framework/UpdateData/UpdateKey.cs | 51 +++++++++++++++++----- src/SMAPI.Web/Controllers/ModsApiController.cs | 48 ++++++++++++-------- src/SMAPI.Web/Framework/ModSiteManager.cs | 28 +++++++++--- src/SMAPI.sln.DotSettings | 1 + 5 files changed, 92 insertions(+), 37 deletions(-) (limited to 'src/SMAPI.Toolkit/Framework') diff --git a/docs/release-notes.md b/docs/release-notes.md index 894fd562..57d32fd7 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -12,6 +12,7 @@ * For modders: * Migrated to Harmony 2.0 (see [_migrate to Harmony 2.0_](https://stardewvalleywiki.com/Modding:Migrate_to_Harmony_2.0) for more info). + * Added [update subkeys](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Update_checks#Update_subkeys). * Added `Multiplayer.PeerConnected` event. * Added `harmony_summary` console command which lists all current Harmony patches, optionally with a search filter. * Harmony mods which use the `[HarmonyPatch(type)]` attribute now work crossplatform. Previously SMAPI couldn't rewrite types in custom attributes for compatibility. diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index f6044148..7e4d0220 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -17,6 +17,9 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// The mod ID within the repository. public string ID { get; } + /// If specified, a substring in download names/descriptions to match. + public string Subkey { get; } + /// Whether the update key seems to be valid. public bool LooksValid { get; } @@ -28,11 +31,13 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// The raw update key text. /// The mod site containing the mod. /// The mod ID within the site. - public UpdateKey(string rawText, ModSiteKey site, string id) + /// If specified, a substring in download names/descriptions to match. + public UpdateKey(string rawText, ModSiteKey site, string id, string subkey) { this.RawText = rawText?.Trim(); this.Site = site; this.ID = id?.Trim(); + this.Subkey = subkey?.Trim(); this.LooksValid = site != ModSiteKey.Unknown && !string.IsNullOrWhiteSpace(id); @@ -41,8 +46,9 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// Construct an instance. /// The mod site containing the mod. /// The mod ID within the site. - public UpdateKey(ModSiteKey site, string id) - : this(UpdateKey.GetString(site, id), site, id) { } + /// If specified, a substring in download names/descriptions to match. + public UpdateKey(ModSiteKey site, string id, string subkey) + : this(UpdateKey.GetString(site, id, subkey), site, id, subkey) { } /// Parse a raw update key. /// The raw update key to parse. @@ -54,7 +60,7 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData { string[] parts = raw?.Trim().Split(':'); if (parts == null || parts.Length != 2) - return new UpdateKey(raw, ModSiteKey.Unknown, null); + return new UpdateKey(raw, ModSiteKey.Unknown, null, null); rawSite = parts[0].Trim(); id = parts[1].Trim(); @@ -62,20 +68,32 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData if (string.IsNullOrWhiteSpace(id)) id = null; + // extract subkey + string subkey = null; + if (id != null) + { + string[] parts = id.Split('@'); + if (parts.Length == 2) + { + id = parts[0].Trim(); + subkey = $"@{parts[1]}".Trim(); + } + } + // parse if (!Enum.TryParse(rawSite, true, out ModSiteKey site)) - return new UpdateKey(raw, ModSiteKey.Unknown, id); + return new UpdateKey(raw, ModSiteKey.Unknown, id, subkey); if (id == null) - return new UpdateKey(raw, site, null); + return new UpdateKey(raw, site, null, subkey); - return new UpdateKey(raw, site, id); + return new UpdateKey(raw, site, id, subkey); } /// Get a string that represents the current object. public override string ToString() { return this.LooksValid - ? UpdateKey.GetString(this.Site, this.ID) + ? UpdateKey.GetString(this.Site, this.ID, this.Subkey) : this.RawText; } @@ -83,10 +101,18 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// An object to compare with this object. public bool Equals(UpdateKey other) { + if (!this.LooksValid) + { + return + other?.LooksValid == false + && this.RawText.Equals(other.RawText, StringComparison.OrdinalIgnoreCase); + } + return other != null && this.Site == other.Site - && string.Equals(this.ID, other.ID, StringComparison.InvariantCultureIgnoreCase); + && string.Equals(this.ID, other.ID, StringComparison.OrdinalIgnoreCase) + && string.Equals(this.Subkey, other.Subkey, StringComparison.OrdinalIgnoreCase); } /// Determines whether the specified object is equal to the current object. @@ -100,15 +126,16 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// A hash code for the current object. public override int GetHashCode() { - return $"{this.Site}:{this.ID}".ToLower().GetHashCode(); + return this.ToString().ToLower().GetHashCode(); } /// Get the string representation of an update key. /// The mod site containing the mod. /// The mod ID within the repository. - public static string GetString(ModSiteKey site, string id) + /// If specified, a substring in download names/descriptions to match. + public static string GetString(ModSiteKey site, string id, string subkey = null) { - return $"{site}:{id}".Trim(); + return $"{site}:{id}{subkey}".Trim(); } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 14be520d..028fc613 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -135,7 +135,7 @@ namespace StardewModdingAPI.Web.Controllers // validate update key if (!updateKey.LooksValid) { - errors.Add($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); + errors.Add($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541', with an optional subkey like 'Nexus:541@subkey'."); continue; } @@ -271,7 +271,7 @@ namespace StardewModdingAPI.Web.Controllers } // get version info - return this.ModSites.GetPageVersions(page, allowNonStandardVersions, mapRemoteVersions); + return this.ModSites.GetPageVersions(page, updateKey.Subkey, allowNonStandardVersions, mapRemoteVersions); } /// Get update keys based on the available mod metadata, while maintaining the precedence order. @@ -280,44 +280,56 @@ namespace StardewModdingAPI.Web.Controllers /// The mod's entry in the wiki list. private IEnumerable GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) { + // get every update key (including duplicates) IEnumerable GetRaw() { // specified update keys if (specifiedKeys != null) { foreach (string key in specifiedKeys) - yield return key?.Trim(); + { + if (!string.IsNullOrWhiteSpace(key)) + yield return key.Trim(); + } } // default update key string defaultKey = record?.GetDefaultUpdateKey(); - if (defaultKey != null) + if (!string.IsNullOrWhiteSpace(defaultKey)) yield return defaultKey; // wiki metadata if (entry != null) { if (entry.NexusID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID?.ToString()); + yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID.ToString()); if (entry.ModDropID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID?.ToString()); + yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID.ToString()); if (entry.CurseForgeID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID?.ToString()); + yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID.ToString()); if (entry.ChucklefishID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID?.ToString()); + yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString()); } } - HashSet seen = new HashSet(); - foreach (string rawKey in GetRaw()) - { - if (string.IsNullOrWhiteSpace(rawKey)) - continue; - - UpdateKey key = UpdateKey.Parse(rawKey); - if (seen.Add(key)) - yield return key; - } + // get unique update keys + var subkeyRoots = new HashSet(); + List updateKeys = GetRaw() + .Select(raw => + { + var key = UpdateKey.Parse(raw); + if (key.Subkey != null) + subkeyRoots.Add(new UpdateKey(key.Site, key.ID, null)); + return key; + }) + .Distinct() + .ToList(); + + // if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority + if (subkeyRoots.Any()) + updateKeys.RemoveAll(subkeyRoots.Contains); + + return updateKeys; } } } diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs index eaae7935..68b4c6ac 100644 --- a/src/SMAPI.Web/Framework/ModSiteManager.cs +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -54,9 +54,10 @@ namespace StardewModdingAPI.Web.Framework /// Parse version info for the given mod page info. /// The mod page info. + /// The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.) /// Maps remote versions to a semantic version for update checks. /// Whether to allow non-standard versions. - public ModInfoModel GetPageVersions(IModPage page, bool allowNonStandardVersions, IDictionary mapRemoteVersions) + public ModInfoModel GetPageVersions(IModPage page, string subkey, bool allowNonStandardVersions, IDictionary mapRemoteVersions) { // get base model ModInfoModel model = new ModInfoModel() @@ -66,7 +67,10 @@ namespace StardewModdingAPI.Web.Framework return model; // fetch versions - if (!this.TryGetLatestVersions(page, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion mainVersion, out ISemanticVersion previewVersion)) + bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion mainVersion, out ISemanticVersion previewVersion); + if (!hasVersions && subkey != null) + hasVersions = this.TryGetLatestVersions(page, null, allowNonStandardVersions, mapRemoteVersions, out mainVersion, out previewVersion); + if (!hasVersions) return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}' has no valid versions."); // return info @@ -96,11 +100,12 @@ namespace StardewModdingAPI.Web.Framework *********/ /// Get the mod version numbers for the given mod. /// The mod to check. + /// The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.) /// Whether to allow non-standard versions. /// Maps remote versions to a semantic version for update checks. /// The main mod version. /// The latest prerelease version, if newer than . - private bool TryGetLatestVersions(IModPage mod, bool allowNonStandardVersions, IDictionary mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) + private bool TryGetLatestVersions(IModPage mod, string subkey, bool allowNonStandardVersions, IDictionary mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) { main = null; preview = null; @@ -113,14 +118,23 @@ namespace StardewModdingAPI.Web.Framework if (mod != null) { - // get versions - main = ParseVersion(mod.Version); - foreach (string rawVersion in mod.Downloads.Select(p => p.Version)) + // get mod version + if (subkey == null) + main = ParseVersion(mod.Version); + + // get file versions + foreach (IModDownload download in mod.Downloads) { - ISemanticVersion cur = ParseVersion(rawVersion); + // check for subkey if specified + if (subkey != null && download.Name?.Contains(subkey, StringComparison.OrdinalIgnoreCase) != true && download.Description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) != true) + continue; + + // parse version + ISemanticVersion cur = ParseVersion(download.Version); if (cur == null) continue; + // track highest versions if (main == null || cur.IsNewerThan(main)) main = cur; if (cur.IsPrerelease() && (preview == null || cur.IsNewerThan(preview))) diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.sln.DotSettings index 556f1ec0..05caa938 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -58,6 +58,7 @@ True True True + True True True True -- cgit From bb9cde8f2e4737627668b75d55660816d7386f06 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 24 May 2020 00:31:15 -0400 Subject: ignore MacOS files starting with ._ --- docs/release-notes.md | 1 + src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) (limited to 'src/SMAPI.Toolkit/Framework') diff --git a/docs/release-notes.md b/docs/release-notes.md index 57d32fd7..10c31143 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -4,6 +4,7 @@ ## Upcoming released * For players: * Mod warnings are now listed alphabetically. + * MacOS files starting with `._` are now ignored and can no longer cause skipped mods. * For the web UI: * Updated web framework to improve site performance and reliability. diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs index f11cc1a7..f4857c7d 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -22,7 +22,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning { // OS metadata files new Regex(@"^__folder_managed_by_vortex$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Vortex mod manager - new Regex(@"^(?:__MACOSX|\._\.DS_Store|\.DS_Store|mcs)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // MacOS + new Regex(@"(?:^\._|^\.DS_Store$|^__MACOSX$|^mcs$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), // MacOS new Regex(@"^(?:desktop\.ini|Thumbs\.db)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Windows new Regex(@"\.(?:url|lnk)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Windows shortcut files -- cgit From d9c2d242b9457a6517ec348c945ae1a324582492 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 24 May 2020 16:39:56 -0400 Subject: add update key overrides --- docs/release-notes.md | 1 + .../Framework/Clients/Wiki/WikiClient.cs | 2 + .../Framework/Clients/Wiki/WikiModEntry.cs | 3 + src/SMAPI.Web/Controllers/ModsApiController.cs | 103 +++++++++++++-------- 4 files changed, 69 insertions(+), 40 deletions(-) (limited to 'src/SMAPI.Toolkit/Framework') diff --git a/docs/release-notes.md b/docs/release-notes.md index 5813298d..90835fa3 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -16,6 +16,7 @@ * Migrated to Harmony 2.0 (see [_migrate to Harmony 2.0_](https://stardewvalleywiki.com/Modding:Migrate_to_Harmony_2.0) for more info). * Added [update subkeys](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Update_checks#Update_subkeys). * Added `Multiplayer.PeerConnected` event. + * Added ability to override update keys from the compatibility list. * Added `harmony_summary` console command which lists all current Harmony patches, optionally with a search filter. * Harmony mods which use the `[HarmonyPatch(type)]` attribute now work crossplatform. Previously SMAPI couldn't rewrite types in custom attributes for compatibility. * Improved mod rewriting for compatibility: diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs index c829c0f4..34e2e1b8 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -105,6 +105,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki string pullRequestUrl = this.GetAttribute(node, "data-pr"); IDictionary mapLocalVersions = this.GetAttributeAsVersionMapping(node, "data-map-local-versions"); IDictionary mapRemoteVersions = this.GetAttributeAsVersionMapping(node, "data-map-remote-versions"); + string[] changeUpdateKeys = this.GetAttributeAsCsv(node, "data-change-update-keys"); // parse stable compatibility WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo @@ -153,6 +154,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki Warnings = warnings, PullRequestUrl = pullRequestUrl, DevNote = devNote, + ChangeUpdateKeys = changeUpdateKeys, MapLocalVersions = mapLocalVersions, MapRemoteVersions = mapRemoteVersions, Anchor = anchor diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs index 474dce3d..21466c6a 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -63,6 +63,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// Special notes intended for developers who maintain unofficial updates or submit pull requests. public string DevNote { get; set; } + /// Update keys to add (optionally prefixed by '+') or remove (prefixed by '-'). + public string[] ChangeUpdateKeys { get; set; } + /// Maps local versions to a semantic version for update checks. public IDictionary MapLocalVersions { get; set; } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 028fc613..db669bf9 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -280,56 +280,79 @@ namespace StardewModdingAPI.Web.Controllers /// The mod's entry in the wiki list. private IEnumerable GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) { - // get every update key (including duplicates) - IEnumerable GetRaw() + // get unique update keys + List updateKeys = this.GetUnfilteredUpdateKeys(specifiedKeys, record, entry) + .Select(UpdateKey.Parse) + .Distinct() + .ToList(); + + // apply remove overrides from wiki + { + var removeKeys = new HashSet( + from key in entry?.ChangeUpdateKeys ?? new string[0] + where key.StartsWith('-') + select UpdateKey.Parse(key.Substring(1)) + ); + if (removeKeys.Any()) + updateKeys.RemoveAll(removeKeys.Contains); + } + + // if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority { - // specified update keys - if (specifiedKeys != null) + var removeKeys = new HashSet(); + foreach (var key in updateKeys) { - foreach (string key in specifiedKeys) - { - if (!string.IsNullOrWhiteSpace(key)) - yield return key.Trim(); - } + if (key.Subkey != null) + removeKeys.Add(new UpdateKey(key.Site, key.ID, null)); } + if (removeKeys.Any()) + updateKeys.RemoveAll(removeKeys.Contains); + } + + return updateKeys; + } + + /// Get every available update key based on the available mod metadata, including duplicates and keys which should be filtered. + /// The specified update keys. + /// The mod's entry in SMAPI's internal database. + /// The mod's entry in the wiki list. + private IEnumerable GetUnfilteredUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) + { + // specified update keys + foreach (string key in specifiedKeys ?? Array.Empty()) + { + if (!string.IsNullOrWhiteSpace(key)) + yield return key.Trim(); + } - // default update key + // default update key + { string defaultKey = record?.GetDefaultUpdateKey(); if (!string.IsNullOrWhiteSpace(defaultKey)) yield return defaultKey; - - // wiki metadata - if (entry != null) - { - if (entry.NexusID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID.ToString()); - if (entry.ModDropID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID.ToString()); - if (entry.CurseForgeID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID.ToString()); - if (entry.ChucklefishID.HasValue) - yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString()); - } } - // get unique update keys - var subkeyRoots = new HashSet(); - List updateKeys = GetRaw() - .Select(raw => - { - var key = UpdateKey.Parse(raw); - if (key.Subkey != null) - subkeyRoots.Add(new UpdateKey(key.Site, key.ID, null)); - return key; - }) - .Distinct() - .ToList(); - - // if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority - if (subkeyRoots.Any()) - updateKeys.RemoveAll(subkeyRoots.Contains); + // wiki metadata + if (entry != null) + { + if (entry.NexusID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.Nexus, entry.NexusID.ToString()); + if (entry.ModDropID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.ModDrop, entry.ModDropID.ToString()); + if (entry.CurseForgeID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.CurseForge, entry.CurseForgeID.ToString()); + if (entry.ChucklefishID.HasValue) + yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString()); + } - return updateKeys; + // overrides from wiki + foreach (string key in entry?.ChangeUpdateKeys ?? Array.Empty()) + { + if (key.StartsWith('+')) + yield return key.Substring(1); + else if (!key.StartsWith("-")) + yield return key; + } } } } -- cgit