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 update key to match in available files. /// The changes to apply to remote versions for update checks. /// Whether to allow non-standard versions. public ModInfoModel GetPageVersions(IModPage page, UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions) { // get ID to show in errors string displayId = page.RequireSubkey ? page.Id + updateKey.Subkey : page.Id; // validate ModInfoModel model = new(); if (!page.IsValid) return model.SetError(page.Status, page.Error); if (page.RequireSubkey && updateKey.Subkey is null) return model.SetError(RemoteModStatus.RequiredSubkeyMissing, $"The {page.Site} mod with ID '{displayId}' requires an update subkey indicating which mod to fetch."); // add basic info (unless it's a manifest, in which case the 'mod page' is the JSON file) if (updateKey.Site != ModSiteKey.UpdateManifest) model.SetBasicInfo(page.Name, page.Url); // fetch versions bool hasVersions = this.TryGetLatestVersions(page, updateKey.Subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, out string? mainModPageUrl, out string? previewModPageUrl); if (!hasVersions) return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{displayId}' has no valid versions."); // apply mod page info model.SetBasicInfo( name: page.GetName(updateKey.Subkey) ?? page.Name, url: page.GetUrl(updateKey.Subkey) ?? page.Url ); // return info return model .SetMainVersion(mainVersion!, mainModPageUrl) .SetPreviewVersion(previewVersion, previewModPageUrl); } /// 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 . /// The mod page URL from which can be downloaded, if different from the 's URL. /// The mod page URL from which can be downloaded, if different from the 's URL. private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview, out string? mainModPageUrl, out string? previewModPageUrl) { main = null; preview = null; mainModPageUrl = null; previewModPageUrl = null; if (mod is null) return false; // parse all versions from the mod page IEnumerable<(IModDownload? download, ISemanticVersion? version)> GetAllVersions() { 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 (download: null, version: modVersion); // get file versions foreach (IModDownload download in mod.Downloads) { ISemanticVersion? cur = ParseAndMapVersion(download.Version); if (cur != null) yield return (download, 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, out string? mainUrl, out string? previewUrl, Func<(IModDownload? download, ISemanticVersion? version), bool>? filter = null) { mainVersion = null; previewVersion = null; mainUrl = null; previewUrl = null; // get latest main + preview version foreach ((IModDownload? download, ISemanticVersion? version) entry in versions) { if (entry.version is null || filter?.Invoke(entry) == false) continue; if (entry.version.IsPrerelease()) { if (previewVersion is null) { previewVersion = entry.version; previewUrl = entry.download?.ModPageUrl; } } else { mainVersion = entry.version; mainUrl = entry.download?.ModPageUrl; break; // any others will be older since entries are sorted by version } } // normalize values if (previewVersion is not null) { if (mainVersion is null) { // if every version is prerelease, latest one is the main version mainVersion = previewVersion; mainUrl = previewUrl; } if (!previewVersion.IsNewerThan(mainVersion)) { previewVersion = null; previewUrl = null; } } } // get versions for subkey if (subkey is not null) TryGetVersions(out main, out preview, out mainModPageUrl, out previewModPageUrl, filter: entry => entry.download?.MatchesSubkey(subkey) == true); // fallback to non-subkey versions if (main is null && !mod.RequireSubkey) TryGetVersions(out main, out preview, out mainModPageUrl, out previewModPageUrl); 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; } } }