using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; 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) { if (!updateKey.LooksValid) return new GenericModPage(updateKey.Site, updateKey.ID!).SetError(RemoteModStatus.DoesNotExist, $"Invalid update key '{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. /// The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.) /// The changes to apply to remote versions for update checks. /// Whether to allow non-standard versions. public ModInfoModel GetPageVersions(IModPage page, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions) { // get base model ModInfoModel model = new(); if (page.IsValid) model.SetBasicInfo(page.Name, page.Url); else { model.SetError(page.Status, page.Error); return model; } // fetch versions 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 return model.SetVersions(mainVersion!, previewVersion); } /// Get a semantic local version for update checks. /// The version to parse. /// Changes to apply to the raw version, if any. /// Whether to allow non-standard versions. public ISemanticVersion? GetMappedVersion(string? version, ChangeDescriptor? map, bool allowNonStandard) { // try mapped version string? rawNewVersion = this.GetRawMappedVersion(version, map); 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. /// 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. /// The changes to apply to remote versions for update checks. /// The main mod version. /// The latest prerelease version, if newer than . private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview) { main = null; preview = null; // parse all versions from the mod page IEnumerable<(string? name, string? description, ISemanticVersion? version)> GetAllVersions() { if (mod != null) { ISemanticVersion? ParseAndMapVersion(string? raw) { raw = this.NormalizeVersion(raw); return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions); } // get mod version ISemanticVersion? modVersion = ParseAndMapVersion(mod.Version); if (modVersion != null) yield return (name: null, description: null, version: ParseAndMapVersion(mod.Version)); // get file versions foreach (IModDownload download in mod.Downloads) { ISemanticVersion? cur = ParseAndMapVersion(download.Version); if (cur != null) yield return (download.Name, download.Description, cur); } } } var versions = GetAllVersions() .OrderByDescending(p => p.version, SemanticVersionComparer.Instance) .ToArray(); // get main + preview versions void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, Func<(string? name, string? description, ISemanticVersion? version), bool>? filter = null) { mainVersion = null; previewVersion = null; // get latest main + preview version foreach ((string? name, string? description, ISemanticVersion? version) entry in versions) { if (entry.version is null || filter?.Invoke(entry) == false) continue; if (entry.version.IsPrerelease()) previewVersion ??= entry.version; else mainVersion ??= entry.version; if (mainVersion != null) break; // any others will be older since entries are sorted by version } // normalize values if (previewVersion is not null) { mainVersion ??= previewVersion; // if every version is prerelease, latest one is the main version if (!previewVersion.IsNewerThan(mainVersion)) previewVersion = null; } } if (subkey is not null) TryGetVersions(out main, out preview, entry => entry.name?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true || entry.description?.Contains(subkey, StringComparison.OrdinalIgnoreCase) == true); if (main is null) TryGetVersions(out main, out preview); return main != null; } /// Get a semantic local version for update checks. /// The version to map. /// Changes to apply to the raw version, if any. private string? GetRawMappedVersion(string? version, ChangeDescriptor? map) { if (version == null || map?.HasChanges != true) return version; var mapped = new List { version }; map.Apply(mapped); return mapped.FirstOrDefault(); } /// 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; } } }