diff options
author | Jesse Plamondon-Willard <github@jplamondonw.com> | 2018-08-01 11:07:29 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <github@jplamondonw.com> | 2018-08-01 11:07:29 -0400 |
commit | 60b41195778af33fd609eab66d9ae3f1d1165e8f (patch) | |
tree | 7128b906d40e94c56c34ed6058f27bc31c31a08b /src/SMAPI.Web | |
parent | b9bc1a6d17cafa0a97b46ffecda432cfc2f23b51 (diff) | |
parent | 52cf953f685c65b2b6814e375ec9a5ffa03c440a (diff) | |
download | SMAPI-60b41195778af33fd609eab66d9ae3f1d1165e8f.tar.gz SMAPI-60b41195778af33fd609eab66d9ae3f1d1165e8f.tar.bz2 SMAPI-60b41195778af33fd609eab66d9ae3f1d1165e8f.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src/SMAPI.Web')
36 files changed, 2658 insertions, 533 deletions
diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs index 0464e50a..8c4a0332 100644 --- a/src/SMAPI.Web/Controllers/IndexController.cs +++ b/src/SMAPI.Web/Controllers/IndexController.cs @@ -1,10 +1,15 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using HtmlAgilityPack; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; -using StardewModdingAPI.Common; +using Microsoft.Extensions.Options; +using StardewModdingAPI.Toolkit; using StardewModdingAPI.Web.Framework.Clients.GitHub; +using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.ViewModels; namespace StardewModdingAPI.Web.Controllers @@ -17,6 +22,9 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Properties *********/ + /// <summary>The site config settings.</summary> + private readonly SiteConfig SiteConfig; + /// <summary>The cache in which to store release data.</summary> private readonly IMemoryCache Cache; @@ -24,7 +32,7 @@ namespace StardewModdingAPI.Web.Controllers private readonly IGitHubClient GitHub; /// <summary>The cache time for release info.</summary> - private readonly TimeSpan CacheTime = TimeSpan.FromSeconds(1); + private readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10); /// <summary>The GitHub repository name to check for update.</summary> private readonly string RepositoryName = "Pathoschild/SMAPI"; @@ -36,34 +44,35 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Construct an instance.</summary> /// <param name="cache">The cache in which to store release data.</param> /// <param name="github">The GitHub API client.</param> - public IndexController(IMemoryCache cache, IGitHubClient github) + /// <param name="siteConfig">The context config settings.</param> + public IndexController(IMemoryCache cache, IGitHubClient github, IOptions<SiteConfig> siteConfig) { this.Cache = cache; this.GitHub = github; + this.SiteConfig = siteConfig.Value; } /// <summary>Display the index page.</summary> [HttpGet] public async Task<ViewResult> Index() { - // fetch SMAPI releases - IndexVersionModel stableVersion = await this.Cache.GetOrCreateAsync("stable-version", async entry => - { - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); - GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false); - return new IndexVersionModel(release.Name, release.Body, this.GetMainDownloadUrl(release), this.GetDevDownloadUrl(release)); - }); - IndexVersionModel betaVersion = await this.Cache.GetOrCreateAsync("beta-version", async entry => - { - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); - GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: true); - return release.IsPrerelease - ? this.GetBetaDownload(release) - : null; - }); + // choose versions + ReleaseVersion[] versions = await this.GetReleaseVersionsAsync(); + ReleaseVersion stableVersion = versions.LastOrDefault(version => !version.IsBeta && !version.IsForDevs); + ReleaseVersion stableVersionForDevs = versions.LastOrDefault(version => !version.IsBeta && version.IsForDevs); + ReleaseVersion betaVersion = versions.LastOrDefault(version => version.IsBeta && !version.IsForDevs); + ReleaseVersion betaVersionForDevs = versions.LastOrDefault(version => version.IsBeta && version.IsForDevs); // render view - var model = new IndexModel(stableVersion, betaVersion); + IndexVersionModel stableVersionModel = stableVersion != null + ? new IndexVersionModel(stableVersion.Version.ToString(), stableVersion.Release.Body, stableVersion.Asset.DownloadUrl, stableVersionForDevs?.Asset.DownloadUrl) + : new IndexVersionModel("unknown", "", "https://github.com/Pathoschild/SMAPI/releases", null); // just in case something goes wrong) + IndexVersionModel betaVersionModel = betaVersion != null && this.SiteConfig.EnableSmapiBeta + ? new IndexVersionModel(betaVersion.Version.ToString(), betaVersion.Release.Body, betaVersion.Asset.DownloadUrl, betaVersionForDevs?.Asset.DownloadUrl) + : null; + + // render view + var model = new IndexModel(stableVersionModel, betaVersionModel); return this.View(model); } @@ -71,62 +80,109 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Private methods *********/ - /// <summary>Get the main download URL for a SMAPI release.</summary> - /// <param name="release">The SMAPI release.</param> - private string GetMainDownloadUrl(GitRelease release) + /// <summary>Get a sorted, parsed list of SMAPI downloads for the latest releases.</summary> + private async Task<ReleaseVersion[]> GetReleaseVersionsAsync() { - // get main download URL - foreach (GitAsset asset in release.Assets ?? new GitAsset[0]) + return await this.Cache.GetOrCreateAsync("available-versions", async entry => { - if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer.zip")) - return asset.DownloadUrl; - } + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); - // fallback just in case - return "https://github.com/pathoschild/SMAPI/releases"; - } + // get latest release (whether preview or stable) + GitRelease stableRelease = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: true); - /// <summary>Get the for-developers download URL for a SMAPI release.</summary> - /// <param name="release">The SMAPI release.</param> - private string GetDevDownloadUrl(GitRelease release) - { - // get dev download URL - foreach (GitAsset asset in release.Assets ?? new GitAsset[0]) - { - if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer-for-developers.zip")) - return asset.DownloadUrl; - } + // split stable/prerelease if applicable + GitRelease betaRelease = null; + if (stableRelease.IsPrerelease) + { + GitRelease result = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false); + if (result != null) + { + betaRelease = stableRelease; + stableRelease = result; + } + } + + // strip 'noinclude' blocks from release descriptions + foreach (GitRelease release in new[] { stableRelease, betaRelease }) + { + if (release == null) + continue; + + HtmlDocument doc = new HtmlDocument(); + doc.LoadHtml(release.Body); + foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? new HtmlNode[0]) + node.Remove(); + release.Body = doc.DocumentNode.InnerHtml.Trim(); + } - // fallback just in case - return "https://github.com/pathoschild/SMAPI/releases"; + // get versions + ReleaseVersion[] stableVersions = this.ParseReleaseVersions(stableRelease).ToArray(); + ReleaseVersion[] betaVersions = this.ParseReleaseVersions(betaRelease).ToArray(); + return stableVersions + .Concat(betaVersions) + .OrderBy(p => p.Version) + .ToArray(); + }); } - /// <summary>Get the latest beta download for a SMAPI release.</summary> - /// <param name="release">The SMAPI release.</param> - private IndexVersionModel GetBetaDownload(GitRelease release) + /// <summary>Get a parsed list of SMAPI downloads for a release.</summary> + /// <param name="release">The GitHub release.</param> + private IEnumerable<ReleaseVersion> ParseReleaseVersions(GitRelease release) { - // get download with the latest version - SemanticVersionImpl latestVersion = null; - string latestUrl = null; - foreach (GitAsset asset in release.Assets ?? new GitAsset[0]) + if (release?.Assets == null) + yield break; + + foreach (GitAsset asset in release.Assets) { - // parse version - Match versionMatch = Regex.Match(asset.FileName, @"SMAPI-([\d\.]+(?:-.+)?)-installer.zip"); - if (!versionMatch.Success || !SemanticVersionImpl.TryParse(versionMatch.Groups[1].Value, out SemanticVersionImpl version)) + Match match = Regex.Match(asset.FileName, @"SMAPI-(?<version>[\d\.]+(?:-.+)?)-installer(?<forDevs>-for-developers)?.zip"); + if (!match.Success || !SemanticVersion.TryParse(match.Groups["version"].Value, out ISemanticVersion version)) continue; + bool isBeta = version.IsPrerelease(); + bool isForDevs = match.Groups["forDevs"].Success; - // save latest version - if (latestVersion == null || latestVersion.CompareTo(version) < 0) - { - latestVersion = version; - latestUrl = asset.DownloadUrl; - } + yield return new ReleaseVersion(release, asset, version, isBeta, isForDevs); } + } - // return if prerelease - return latestVersion?.Tag != null - ? new IndexVersionModel(latestVersion.ToString(), release.Body, latestUrl, null) - : null; + /// <summary>A parsed release download.</summary> + private class ReleaseVersion + { + /********* + ** Accessors + *********/ + /// <summary>The underlying GitHub release.</summary> + public GitRelease Release { get; } + + /// <summary>The underlying download asset.</summary> + public GitAsset Asset { get; } + + /// <summary>The SMAPI version.</summary> + public ISemanticVersion Version { get; } + + /// <summary>Whether this is a beta download.</summary> + public bool IsBeta { get; } + + /// <summary>Whether this is a 'for developers' download.</summary> + public bool IsForDevs { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="release">The underlying GitHub release.</param> + /// <param name="asset">The underlying download asset.</param> + /// <param name="version">The SMAPI version.</param> + /// <param name="isBeta">Whether this is a beta download.</param> + /// <param name="isForDevs">Whether this is a 'for developers' download.</param> + public ReleaseVersion(GitRelease release, GitAsset asset, ISemanticVersion version, bool isBeta, bool isForDevs) + { + this.Release = release; + this.Asset = asset; + this.Version = version; + this.IsBeta = isBeta; + this.IsForDevs = isForDevs; + } } } } diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index 62547deb..17f8d3aa 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.IO.Compression; +using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; @@ -20,8 +21,8 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Properties *********/ - /// <summary>The log parser config settings.</summary> - private readonly ContextConfig Config; + /// <summary>The site config settings.</summary> + private readonly SiteConfig Config; /// <summary>The underlying Pastebin client.</summary> private readonly IPastebinClient Pastebin; @@ -38,11 +39,11 @@ namespace StardewModdingAPI.Web.Controllers ** Constructor ***/ /// <summary>Construct an instance.</summary> - /// <param name="contextProvider">The context config settings.</param> + /// <param name="siteConfig">The context config settings.</param> /// <param name="pastebin">The Pastebin API client.</param> - public LogParserController(IOptions<ContextConfig> contextProvider, IPastebinClient pastebin) + public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin) { - this.Config = contextProvider.Value; + this.Config = siteConfig.Value; this.Pastebin = pastebin; } @@ -51,34 +52,49 @@ namespace StardewModdingAPI.Web.Controllers ***/ /// <summary>Render the log parser UI.</summary> /// <param name="id">The paste ID.</param> + /// <param name="raw">Whether to display the raw unparsed log.</param> [HttpGet] [Route("log")] [Route("log/{id}")] - public async Task<ViewResult> Index(string id = null) + public async Task<ViewResult> Index(string id = null, bool raw = false) { // fresh page if (string.IsNullOrWhiteSpace(id)) - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, null)); + return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id)); // log page PasteInfo paste = await this.GetAsync(id); ParsedLog log = paste.Success ? new LogParser().Parse(paste.Content) : new ParsedLog { IsValid = false, Error = "Pastebin error: " + paste.Error }; - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log)); + return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log, raw)); } /*** ** JSON ***/ /// <summary>Save raw log data.</summary> - /// <param name="content">The log content to save.</param> - [HttpPost, Produces("application/json"), AllowLargePosts] - [Route("log/save")] - public async Task<SavePasteResult> PostAsync([FromBody] string content) + [HttpPost, AllowLargePosts] + [Route("log")] + public async Task<ActionResult> PostAsync() { - content = this.CompressString(content); - return await this.Pastebin.PostAsync(content); + // get raw log text + string input = this.Request.Form["input"].FirstOrDefault(); + if (string.IsNullOrWhiteSpace(input)) + return this.View("Index", new LogParserModel(this.Config.LogParserUrl, null) { UploadError = "The log file seems to be empty." }); + + // upload log + input = this.CompressString(input); + SavePasteResult result = await this.Pastebin.PostAsync(input); + + // handle errors + if (!result.Success) + return this.View("Index", new LogParserModel(this.Config.LogParserUrl, result.ID) { UploadError = $"Pastebin error: {result.Error ?? "unknown error"}" }); + + // redirect to view + UriBuilder uri = new UriBuilder(new Uri(this.Config.LogParserUrl)); + uri.Path = uri.Path.TrimEnd('/') + '/' + result.ID; + return this.Redirect(uri.Uri.ToString()); } @@ -115,7 +131,7 @@ namespace StardewModdingAPI.Web.Controllers } // prefix length - var zipBuffer = new byte[compressedData.Length + 4]; + byte[] zipBuffer = new byte[compressedData.Length + 4]; Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length); Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4); @@ -151,7 +167,7 @@ namespace StardewModdingAPI.Web.Controllers memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4); // read data - var buffer = new byte[dataLength]; + byte[] buffer = new byte[dataLength]; memoryStream.Position = 0; using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) gZipStream.Read(buffer, 0, buffer.Length); diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 24517263..b500e19d 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -1,12 +1,17 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using StardewModdingAPI.Common.Models; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.Nexus; @@ -38,19 +43,28 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>A regex which matches SMAPI-style semantic version.</summary> private readonly string VersionRegex; + /// <summary>The internal mod metadata list.</summary> + private readonly ModDatabase ModDatabase; + + /// <summary>The web URL for the wiki compatibility list.</summary> + private readonly string WikiCompatibilityPageUrl; + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> + /// <param name="environment">The web hosting environment.</param> /// <param name="cache">The cache in which to store mod metadata.</param> /// <param name="configProvider">The config settings for mod update checks.</param> /// <param name="chucklefish">The Chucklefish API client.</param> /// <param name="github">The GitHub API client.</param> /// <param name="nexus">The Nexus API client.</param> - public ModsApiController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus) + public ModsApiController(IHostingEnvironment environment, IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus) { + this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "StardewModdingAPI.metadata.json")); ModUpdateCheckConfig config = configProvider.Value; + this.WikiCompatibilityPageUrl = config.WikiCompatibilityPageUrl; this.Cache = cache; this.SuccessCacheMinutes = config.SuccessCacheMinutes; @@ -67,76 +81,126 @@ namespace StardewModdingAPI.Web.Controllers } /// <summary>Fetch version metadata for the given mods.</summary> - /// <param name="modKeys">The namespaced mod keys to search as a comma-delimited array.</param> - /// <param name="allowInvalidVersions">Whether to allow non-semantic versions, instead of returning an error for those.</param> - [HttpGet] - public async Task<IDictionary<string, ModInfoModel>> GetAsync(string modKeys, bool allowInvalidVersions = false) + /// <param name="model">The mod search criteria.</param> + [HttpPost] + public async Task<object> PostAsync([FromBody] ModSearchModel model) { - string[] modKeysArray = modKeys?.Split(',').ToArray(); - if (modKeysArray == null || !modKeysArray.Any()) - return new Dictionary<string, ModInfoModel>(); + // parse request data + ISemanticVersion apiVersion = this.GetApiVersion(); + ModSearchEntryModel[] searchMods = this.GetSearchMods(model, apiVersion).ToArray(); + + // fetch wiki data + WikiCompatibilityEntry[] wikiData = await this.GetWikiDataAsync(); + + // fetch data + IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase); + foreach (ModSearchEntryModel mod in searchMods) + { + if (string.IsNullOrWhiteSpace(mod.ID)) + continue; + + ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata); + result.SetBackwardsCompatibility(apiVersion); + mods[mod.ID] = result; + } - return await this.PostAsync(new ModSearchModel(modKeysArray, allowInvalidVersions)); + // return in expected structure + return apiVersion.IsNewerThan("2.6-beta.18") + ? mods.Values + : (object)mods; } - /// <summary>Fetch version metadata for the given mods.</summary> - /// <param name="search">The mod search criteria.</param> - [HttpPost] - public async Task<IDictionary<string, ModInfoModel>> PostAsync([FromBody] ModSearchModel search) + + /********* + ** Private methods + *********/ + /// <summary>Get the metadata for a mod.</summary> + /// <param name="search">The mod data to match.</param> + /// <param name="wikiData">The wiki data.</param> + /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param> + /// <returns>Returns the mod data if found, else <c>null</c>.</returns> + private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiCompatibilityEntry[] wikiData, bool includeExtendedMetadata) { - // parse model - bool allowInvalidVersions = search?.AllowInvalidVersions ?? false; - string[] modKeys = (search?.ModKeys?.ToArray() ?? new string[0]) - .Distinct(StringComparer.CurrentCultureIgnoreCase) - .OrderBy(p => p, StringComparer.CurrentCultureIgnoreCase) - .ToArray(); + // resolve update keys + var updateKeys = new HashSet<string>(search.UpdateKeys ?? new string[0], StringComparer.InvariantCultureIgnoreCase); + ModDataRecord record = this.ModDatabase.Get(search.ID); + if (record?.Fields != null) + { + string defaultUpdateKey = record.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value; + if (!string.IsNullOrWhiteSpace(defaultUpdateKey)) + updateKeys.Add(defaultUpdateKey); + } - // fetch mod info - IDictionary<string, ModInfoModel> result = new Dictionary<string, ModInfoModel>(StringComparer.CurrentCultureIgnoreCase); - foreach (string modKey in modKeys) + // get latest versions + ModEntryModel result = new ModEntryModel { ID = search.ID }; + IList<string> errors = new List<string>(); + foreach (string updateKey in updateKeys) { - // parse mod key - if (!this.TryParseModKey(modKey, out string vendorKey, out string modID)) + // fetch data + ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey); + if (data.Error != null) { - result[modKey] = new ModInfoModel("The mod key isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); + errors.Add(data.Error); continue; } - // get matching repository - if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository)) + // handle main version + if (data.Version != null) { - result[modKey] = new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); - continue; + if (!SemanticVersion.TryParse(data.Version, out ISemanticVersion version)) + { + errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'."); + continue; + } + + if (this.IsNewer(version, result.Main?.Version)) + result.Main = new ModEntryVersionModel(version, data.Url); } - // fetch mod info - result[modKey] = await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry => + // handle optional version + if (data.PreviewVersion != null) { - // fetch info - ModInfoModel info = await repository.GetModInfoAsync(modID); - - // validate - if (info.Error == null) + if (!SemanticVersion.TryParse(data.PreviewVersion, out ISemanticVersion version)) { - if (info.Version == null) - info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: "Mod has no version number."); - if (!allowInvalidVersions && !Regex.IsMatch(info.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) - info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: $"Mod has invalid semantic version '{info.Version}'."); + errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'."); + continue; } - // cache & return - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(info.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes); - return info; - }); + if (this.IsNewer(version, result.Optional?.Version)) + result.Optional = new ModEntryVersionModel(version, data.Url); + } } + // get unofficial version + WikiCompatibilityEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(result.ID.Trim(), StringComparer.InvariantCultureIgnoreCase)); + if (wikiEntry?.UnofficialVersion != null && this.IsNewer(wikiEntry.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.UnofficialVersion, result.Optional?.Version)) + result.Unofficial = new ModEntryVersionModel(wikiEntry.UnofficialVersion, this.WikiCompatibilityPageUrl); + + // fallback to preview if latest is invalid + if (result.Main == null && result.Optional != null) + { + result.Main = result.Optional; + result.Optional = null; + } + + // special cases + if (result.ID == "Pathoschild.SMAPI") + { + if (result.Main != null) + result.Main.Url = "https://smapi.io/"; + if (result.Optional != null) + result.Optional.Url = "https://smapi.io/"; + } + + // add extended metadata + if (includeExtendedMetadata && (wikiEntry != null || record != null)) + result.Metadata = new ModExtendedMetadataModel(wikiEntry, record); + + // add result + result.Errors = errors.ToArray(); return result; } - - /********* - ** Private methods - *********/ /// <summary>Parse a namespaced mod ID.</summary> /// <param name="raw">The raw mod ID to parse.</param> /// <param name="vendorKey">The parsed vendor key.</param> @@ -158,5 +222,91 @@ namespace StardewModdingAPI.Web.Controllers modID = parts[1].Trim(); return true; } + + /// <summary>Get whether a <paramref name="current"/> version is newer than an <paramref name="other"/> version.</summary> + /// <param name="current">The current version.</param> + /// <param name="other">The other version.</param> + private bool IsNewer(ISemanticVersion current, ISemanticVersion other) + { + return current != null && (other == null || other.IsOlderThan(current)); + } + + /// <summary>Get the mods for which the API should return data.</summary> + /// <param name="model">The search model.</param> + /// <param name="apiVersion">The requested API version.</param> + private IEnumerable<ModSearchEntryModel> GetSearchMods(ModSearchModel model, ISemanticVersion apiVersion) + { + if (model == null) + yield break; + + // yield standard entries + if (model.Mods != null) + { + foreach (ModSearchEntryModel mod in model.Mods) + yield return mod; + } + + // yield mod update keys if backwards compatible + if (model.ModKeys != null && model.ModKeys.Any() && !apiVersion.IsNewerThan("2.6-beta.17")) + { + foreach (string updateKey in model.ModKeys.Distinct()) + yield return new ModSearchEntryModel(updateKey, new[] { updateKey }); + } + } + + /// <summary>Get mod data from the wiki compatibility list.</summary> + private async Task<WikiCompatibilityEntry[]> GetWikiDataAsync() + { + ModToolkit toolkit = new ModToolkit(); + return await this.Cache.GetOrCreateAsync($"_wiki", async entry => + { + try + { + WikiCompatibilityEntry[] entries = await toolkit.GetWikiCompatibilityListAsync(); + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.SuccessCacheMinutes); + return entries; + } + catch + { + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.ErrorCacheMinutes); + return new WikiCompatibilityEntry[0]; + } + }); + } + + /// <summary>Get the mod info for an update key.</summary> + /// <param name="updateKey">The namespaced update key.</param> + private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(string updateKey) + { + // parse update key + if (!this.TryParseModKey(updateKey, out string vendorKey, out string modID)) + return new ModInfoModel($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); + + // get matching repository + if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository)) + return new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); + + // fetch mod info + return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry => + { + ModInfoModel result = await repository.GetModInfoAsync(modID); + if (result.Error != null) + { + if (result.Version == null) + result.Error = $"The update key '{updateKey}' matches a mod with no version number."; + else if (!Regex.IsMatch(result.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) + result.Error = $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'."; + } + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(result.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes); + return result; + }); + } + + /// <summary>Get the requested API version.</summary> + private ISemanticVersion GetApiVersion() + { + string actualVersion = (string)this.RouteData.Values["version"]; + return new SemanticVersion(actualVersion); + } } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs deleted file mode 100644 index adec41be..00000000 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Threading.Tasks; -using Pathoschild.Http.Client; - -namespace StardewModdingAPI.Web.Framework.Clients.Nexus -{ - /// <summary>An HTTP client for fetching mod metadata from the Nexus Mods API.</summary> - internal class NexusClient : INexusClient - { - /********* - ** Properties - *********/ - /// <summary>The URL for a Nexus Mods API query excluding the base URL, where {0} is the mod ID.</summary> - private readonly string ModUrlFormat; - - /// <summary>The underlying HTTP client.</summary> - private readonly IClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="userAgent">The user agent for the Nexus Mods API client.</param> - /// <param name="baseUrl">The base URL for the Nexus Mods API.</param> - /// <param name="modUrlFormat">The URL for a Nexus Mods API query excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param> - public NexusClient(string userAgent, string baseUrl, string modUrlFormat) - { - this.ModUrlFormat = modUrlFormat; - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - } - - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The Nexus mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - public async Task<NexusMod> GetModAsync(uint id) - { - return await this.Client - .GetAsync(string.Format(this.ModUrlFormat, id)) - .As<NexusMod>(); - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public void Dispose() - { - this.Client?.Dispose(); - } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs index cd52c72b..4ecf2f76 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs @@ -1,4 +1,5 @@ using Newtonsoft.Json; +using StardewModdingAPI.Toolkit; namespace StardewModdingAPI.Web.Framework.Clients.Nexus { @@ -14,6 +15,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /// <summary>The mod's semantic version number.</summary> public string Version { get; set; } + /// <summary>The latest file version.</summary> + public ISemanticVersion LatestFileVersion { get; set; } + /// <summary>The mod's web URL.</summary> [JsonProperty("mod_page_uri")] public string Url { get; set; } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs index d0597965..1b3fa195 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs @@ -1,8 +1,11 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading.Tasks; using HtmlAgilityPack; using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit; namespace StardewModdingAPI.Web.Framework.Clients.Nexus { @@ -12,9 +15,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /********* ** Properties *********/ - /// <summary>The URL for a Nexus web page excluding the base URL, where {0} is the mod ID.</summary> + /// <summary>The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID.</summary> private readonly string ModUrlFormat; + /// <summary>The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</summary> + public string ModScrapeUrlFormat { get; set; } + /// <summary>The underlying HTTP client.</summary> private readonly IClient Client; @@ -25,10 +31,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /// <summary>Construct an instance.</summary> /// <param name="userAgent">The user agent for the Nexus Mods API client.</param> /// <param name="baseUrl">The base URL for the Nexus Mods site.</param> - /// <param name="modUrlFormat">The URL for a Nexus Mods web page excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param> - public NexusWebScrapeClient(string userAgent, string baseUrl, string modUrlFormat) + /// <param name="modUrlFormat">The URL for a Nexus Mods mod page for the user, excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param> + /// <param name="modScrapeUrlFormat">The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</param> + public NexusWebScrapeClient(string userAgent, string baseUrl, string modUrlFormat, string modScrapeUrlFormat) { this.ModUrlFormat = modUrlFormat; + this.ModScrapeUrlFormat = modScrapeUrlFormat; this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); } @@ -42,7 +50,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus try { html = await this.Client - .GetAsync(string.Format(this.ModUrlFormat, id)) + .GetAsync(string.Format(this.ModScrapeUrlFormat, id)) .AsString(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) @@ -75,11 +83,43 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus string url = this.GetModUrl(id); string name = doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim(); string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); + SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion); + + // extract file versions + List<string> rawVersions = new List<string>(); + 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; + + latestFileVersion = cur; + } + // yield info return new NexusMod { Name = name, - Version = version, + Version = parsedVersion?.ToString() ?? version, + LatestFileVersion = latestFileVersion, Url = url }; } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index de6c024a..ae8f18d2 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -47,24 +47,21 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /**** ** Nexus Mods ****/ - /// <summary>The user agent for the Nexus Mods API client.</summary> - public string NexusUserAgent { get; set; } - /// <summary>The base URL for the Nexus Mods API.</summary> public string NexusBaseUrl { get; set; } - /// <summary>The URL for a Nexus Mods API query excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary> + /// <summary>The URL for a Nexus mod page for the user, excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary> public string NexusModUrlFormat { get; set; } + /// <summary>The URL for a Nexus mod page to scrape for versions, excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary> + public string NexusModScrapeUrlFormat { get; set; } + /**** ** Pastebin ****/ /// <summary>The base URL for the Pastebin API.</summary> public string PastebinBaseUrl { get; set; } - /// <summary>The user agent for the Pastebin API client, where {0} is the SMAPI version.</summary> - public string PastebinUserAgent { get; set; } - /// <summary>The user key used to authenticate with the Pastebin API.</summary> public string PastebinUserKey { get; set; } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index fc3b7dc2..ce4f3cb5 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -24,5 +24,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The repository key for Nexus Mods.</summary> public string NexusKey { get; set; } + + /// <summary>The web URL for the wiki compatibility list.</summary> + public string WikiCompatibilityPageUrl { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index 117462f4..3d428015 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ContextConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -1,7 +1,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels { - /// <summary>The config settings for the app context.</summary> - public class ContextConfig // must be public to pass into views + /// <summary>The site config settings.</summary> + public class SiteConfig // must be public to pass into views { /********* ** Accessors @@ -11,5 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The root URL for the log parser.</summary> public string LogParserUrl { get; set; } + + /// <summary>Whether to show SMAPI beta versions on the main page, if any.</summary> + public bool EnableSmapiBeta { get; set; } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index f49fb05c..013c6c47 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; -using StardewModdingAPI.Common; +using StardewModdingAPI.Toolkit; using StardewModdingAPI.Web.Framework.LogParsing.Models; namespace StardewModdingAPI.Web.Framework.LogParsing @@ -31,13 +31,13 @@ namespace StardewModdingAPI.Web.Framework.LogParsing /// <summary>A regex pattern matching an entry in SMAPI's mod list.</summary> /// <remarks>The author name and description are optional.</remarks> - private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersionImpl.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's content pack list.</summary> private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary> - private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| for (?<for>.+?) \| (?<description>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| for (?<for>.+?)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /********* @@ -135,7 +135,10 @@ namespace StardewModdingAPI.Web.Framework.LogParsing { Match match = this.ModPathPattern.Match(message.Text); log.ModPath = match.Groups["path"].Value; - log.GamePath = new FileInfo(log.ModPath).Directory.FullName; + int lastDelimiterPos = log.ModPath.LastIndexOfAny(new char[] { '/', '\\' }); + log.GamePath = lastDelimiterPos >= 0 + ? log.ModPath.Substring(0, lastDelimiterPos) + : log.ModPath; } // log UTC timestamp line diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs index 40d21bf8..759f15db 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs @@ -1,24 +1,29 @@ +using StardewModdingAPI.Internal.ConsoleWriting; + namespace StardewModdingAPI.Web.Framework.LogParsing.Models { /// <summary>The log severity levels.</summary> public enum LogLevel { /// <summary>Tracing info intended for developers.</summary> - Trace, + Trace = ConsoleLogLevel.Trace, /// <summary>Troubleshooting info that may be relevant to the player.</summary> - Debug, + Debug = ConsoleLogLevel.Debug, /// <summary>Info relevant to the player. This should be used judiciously.</summary> - Info, + Info = ConsoleLogLevel.Info, /// <summary>An issue the player should be aware of. This should be used rarely.</summary> - Warn, + Warn = ConsoleLogLevel.Warn, /// <summary>A message indicating something went wrong.</summary> - Error, + Error = ConsoleLogLevel.Error, /// <summary>Important information to highlight for the player when player action is needed (e.g. new version available). This should be used rarely to avoid alert fatigue.</summary> - Alert + Alert = ConsoleLogLevel.Alert, + + /// <summary>A critical issue that generally signals an immediate end to the application.</summary> + Critical = ConsoleLogLevel.Critical } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs index edb00454..4a4a40cd 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; -using StardewModdingAPI.Common.Models; +using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; namespace StardewModdingAPI.Web.Framework.ModRepositories { diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs index 3e5a4272..e6074a60 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs @@ -1,6 +1,6 @@ using System; using System.Threading.Tasks; -using StardewModdingAPI.Common.Models; +using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; namespace StardewModdingAPI.Web.Framework.ModRepositories diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs index 59eb8cd1..1d7e4fff 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs @@ -1,6 +1,6 @@ using System; using System.Threading.Tasks; -using StardewModdingAPI.Common.Models; +using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Web.Framework.Clients.GitHub; namespace StardewModdingAPI.Web.Framework.ModRepositories @@ -38,21 +38,25 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories // fetch info try { - // get latest release + // get latest release (whether preview or stable) GitRelease latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: true); - GitRelease preview = null; if (latest == null) return new ModInfoModel("Found no mod with this ID."); - // get latest stable release (if not latest) + // split stable/prerelease if applicable + GitRelease preview = null; if (latest.IsPrerelease) { - preview = latest; - latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false); + GitRelease result = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false); + if (result != null) + { + preview = latest; + latest = result; + } } // return data - return new ModInfoModel(name: id, version: this.NormaliseVersion(latest?.Tag), previewVersion: this.NormaliseVersion(preview?.Tag), url: $"https://github.com/{id}/releases"); + return new ModInfoModel(name: id, version: this.NormaliseVersion(latest.Tag), previewVersion: this.NormaliseVersion(preview?.Tag), url: $"https://github.com/{id}/releases"); } catch (Exception ex) { diff --git a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs index 4496400c..09c59a86 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs @@ -1,6 +1,5 @@ using System; using System.Threading.Tasks; -using StardewModdingAPI.Common.Models; namespace StardewModdingAPI.Web.Framework.ModRepositories { diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs new file mode 100644 index 00000000..18252298 --- /dev/null +++ b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs @@ -0,0 +1,56 @@ +namespace StardewModdingAPI.Web.Framework.ModRepositories +{ + /// <summary>Generic metadata about a mod.</summary> + internal class ModInfoModel + { + /********* + ** Accessors + *********/ + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>The mod's latest release number.</summary> + public string Version { get; set; } + + /// <summary>The mod's latest optional release, if newer than <see cref="Version"/>.</summary> + public string PreviewVersion { get; set; } + + /// <summary>The mod's web URL.</summary> + public string Url { get; set; } + + /// <summary>The error message indicating why the mod is invalid (if applicable).</summary> + public string Error { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an empty instance.</summary> + public ModInfoModel() + { + // needed for JSON deserialising + } + + /// <summary>Construct an instance.</summary> + /// <param name="name">The mod name.</param> + /// <param name="version">The semantic version for the mod's latest release.</param> + /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param> + /// <param name="url">The mod's web URL.</param> + /// <param name="error">The error message indicating why the mod is invalid (if applicable).</param> + public ModInfoModel(string name, string version, string url, string previewVersion = null, string error = null) + { + this.Name = name; + this.Version = version; + this.PreviewVersion = previewVersion; + this.Url = url; + this.Error = error; + } + + /// <summary>Construct an instance.</summary> + /// <param name="error">The error message indicating why the mod is invalid.</param> + public ModInfoModel(string error) + { + this.Error = error; + } + } +} diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs index 6411ad4c..4afcda10 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs @@ -1,6 +1,6 @@ using System; using System.Threading.Tasks; -using StardewModdingAPI.Common.Models; +using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Web.Framework.Clients.Nexus; namespace StardewModdingAPI.Web.Framework.ModRepositories @@ -43,7 +43,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories return new ModInfoModel("Found no mod with this ID."); if (mod.Error != null) return new ModInfoModel(mod.Error); - return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), url: mod.Url); + return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), previewVersion: mod.LatestFileVersion?.ToString(), url: mod.Url); } catch (Exception ex) { diff --git a/src/SMAPI.Web/Framework/VersionConstraint.cs b/src/SMAPI.Web/Framework/VersionConstraint.cs index cffb1092..2d6ec603 100644 --- a/src/SMAPI.Web/Framework/VersionConstraint.cs +++ b/src/SMAPI.Web/Framework/VersionConstraint.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.Routing.Constraints; -using StardewModdingAPI.Common; +using StardewModdingAPI.Toolkit; namespace StardewModdingAPI.Web.Framework { @@ -11,6 +11,6 @@ namespace StardewModdingAPI.Web.Framework *********/ /// <summary>Construct an instance.</summary> public VersionConstraint() - : base(SemanticVersionImpl.Regex) { } + : base(SemanticVersion.Regex) { } } } diff --git a/src/SMAPI.Web/StardewModdingAPI.Web.csproj b/src/SMAPI.Web/StardewModdingAPI.Web.csproj index e2eee8a8..6761c7ad 100644 --- a/src/SMAPI.Web/StardewModdingAPI.Web.csproj +++ b/src/SMAPI.Web/StardewModdingAPI.Web.csproj @@ -10,18 +10,26 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="HtmlAgilityPack" Version="1.7.2" /> - <PackageReference Include="Markdig" Version="0.14.9" /> - <PackageReference Include="Microsoft.AspNetCore" Version="2.0.2" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.0.3" /> - <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.0.2" /> - <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.0.2" /> - <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.0.1" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.1.0" /> + <PackageReference Include="HtmlAgilityPack" Version="1.8.4" /> + <PackageReference Include="Markdig" Version="0.15.0" /> + <PackageReference Include="Microsoft.AspNetCore" Version="2.1.1" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.1" /> + <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.1.1" /> + <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.1.1" /> + <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.1.1" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.2.0" /> </ItemGroup> <ItemGroup> <DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" /> </ItemGroup> - <Import Project="..\SMAPI.Common\StardewModdingAPI.Common.projitems" Label="Shared" /> + <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> + <ItemGroup> + <ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj" /> + </ItemGroup> + <ItemGroup> + <Content Update="wwwroot\StardewModdingAPI.metadata.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + </ItemGroup> </Project> diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 6c7ccecd..bf3ec9a1 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Serialisation; using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; using StardewModdingAPI.Web.Framework.Clients.GitHub; @@ -49,13 +50,16 @@ namespace StardewModdingAPI.Web // init configuration services .Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck")) - .Configure<ContextConfig>(this.Configuration.GetSection("Context")) + .Configure<SiteConfig>(this.Configuration.GetSection("Site")) .Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint))) .AddMemoryCache() .AddMvc() .ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider())) .AddJsonOptions(options => { + foreach (JsonConverter converter in new JsonHelper().JsonSettings.Converters) + options.SerializerSettings.Converters.Add(converter); + options.SerializerSettings.Formatting = Formatting.Indented; options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; }); @@ -82,15 +86,11 @@ namespace StardewModdingAPI.Web password: api.GitHubPassword )); - //services.AddSingleton<INexusClient>(new NexusClient( - // userAgent: api.NexusUserAgent, - // baseUrl: api.NexusBaseUrl, - // modUrlFormat: api.NexusModUrlFormat - //)); services.AddSingleton<INexusClient>(new NexusWebScrapeClient( userAgent: userAgent, baseUrl: api.NexusBaseUrl, - modUrlFormat: api.NexusModUrlFormat + modUrlFormat: api.NexusModUrlFormat, + modScrapeUrlFormat: api.NexusModScrapeUrlFormat )); services.AddSingleton<IPastebinClient>(new PastebinClient( @@ -153,23 +153,23 @@ namespace StardewModdingAPI.Web )); // shortcut redirects + redirects.Add(new RedirectToUrlRule(@"^/buildmsg(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#$1")); redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://stardewvalleywiki.com/Modding:SMAPI_compatibility")); redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index")); - redirects.Add(new RedirectToUrlRule(@"^/buildmsg(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#$1")); + redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI")); // redirect legacy canimod.com URLs var wikiRedirects = new Dictionary<string, string[]> { - ["Modding:Creating_a_SMAPI_mod"] = new[] { "^/for-devs/creating-a-smapi-mod", "^/guides/creating-a-smapi-mod" }, + ["Modding:Index#Migration_guides"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" }, + ["Modding:Modder_Guide"] = new[] { "^/for-devs/creating-a-smapi-mod", "^/guides/creating-a-smapi-mod", "^/for-devs/creating-a-smapi-mod-advanced-config" }, + ["Modding:Player_Guide"] = new[] { "^/for-players/install-smapi", "^/guides/using-mods", "^/for-players/faqs", "^/for-players/intro", "^/for-players/use-mods", "^/guides/asking-for-help", "^/guides/smapi-faq" }, + ["Modding:Editing_XNB_files"] = new[] { "^/for-devs/creating-an-xnb-mod", "^/guides/creating-an-xnb-mod" }, ["Modding:Event_data"] = new[] { "^/for-devs/events", "^/guides/events" }, ["Modding:Gift_taste_data"] = new[] { "^/for-devs/npc-gift-tastes", "^/guides/npc-gift-tastes" }, ["Modding:IDE_reference"] = new[] { "^/for-devs/creating-a-smapi-mod-ide-primer" }, - ["Modding:Installing_SMAPI"] = new[] { "^/for-players/install-smapi", "^/guides/using-mods" }, ["Modding:Object_data"] = new[] { "^/for-devs/object-data", "^/guides/object-data" }, - ["Modding:Player_FAQs"] = new[] { "^/for-players/faqs", "^/for-players/intro", "^/for-players/use-mods", "^/guides/asking-for-help", "^/guides/smapi-faq" }, - ["Modding:SMAPI_APIs"] = new[] { "^/for-devs/creating-a-smapi-mod-advanced-config" }, - ["Modding:Updating_deprecated_SMAPI_code"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" }, ["Modding:Weather_data"] = new[] { "^/for-devs/weather", "^/guides/weather" } }; foreach (KeyValuePair<string, string[]> pair in wikiRedirects) diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs index 8c026536..df36ca73 100644 --- a/src/SMAPI.Web/ViewModels/LogParserModel.cs +++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; using StardewModdingAPI.Web.Framework.LogParsing.Models; namespace StardewModdingAPI.Web.ViewModels @@ -6,6 +9,13 @@ namespace StardewModdingAPI.Web.ViewModels public class LogParserModel { /********* + ** Properties + *********/ + /// <summary>A regex pattern matching characters to remove from a mod name to create the slug ID.</summary> + private readonly Regex SlugInvalidCharPattern = new Regex("[^a-z0-9]", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + + /********* ** Accessors *********/ /// <summary>The root URL for the log parser controller.</summary> @@ -17,6 +27,15 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>The parsed log info.</summary> public ParsedLog ParsedLog { get; set; } + /// <summary>Whether to show the raw unparsed log.</summary> + public bool ShowRaw { get; set; } + + /// <summary>An error which occurred while uploading the log to Pastebin.</summary> + public string UploadError { get; set; } + + /// <summary>An error which occurred while parsing the log file.</summary> + public string ParseError => this.ParsedLog?.Error; + /********* ** Public methods @@ -27,12 +46,46 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>Construct an instance.</summary> /// <param name="sectionUrl">The root URL for the log parser controller.</param> /// <param name="pasteID">The paste ID.</param> - /// <param name="parsedLog">The parsed log info.</param> - public LogParserModel(string sectionUrl, string pasteID, ParsedLog parsedLog) + public LogParserModel(string sectionUrl, string pasteID) { this.SectionUrl = sectionUrl; this.PasteID = pasteID; + this.ParsedLog = null; + this.ShowRaw = false; + } + + /// <summary>Construct an instance.</summary> + /// <param name="sectionUrl">The root URL for the log parser controller.</param> + /// <param name="pasteID">The paste ID.</param> + /// <param name="parsedLog">The parsed log info.</param> + /// <param name="showRaw">Whether to show the raw unparsed log.</param> + public LogParserModel(string sectionUrl, string pasteID, ParsedLog parsedLog, bool showRaw) + : this(sectionUrl, pasteID) + { this.ParsedLog = parsedLog; + this.ShowRaw = showRaw; + } + + /// <summary>Get all content packs in the log grouped by the mod they're for.</summary> + public IDictionary<string, LogModInfo[]> GetContentPacksByMod() + { + // get all mods & content packs + LogModInfo[] mods = this.ParsedLog?.Mods; + if (mods == null || !mods.Any()) + return new Dictionary<string, LogModInfo[]>(); + + // group by mod + return mods + .Where(mod => mod.ContentPackFor != null) + .GroupBy(mod => mod.ContentPackFor) + .ToDictionary(group => group.Key, group => group.ToArray()); + } + + /// <summary>Get a sanitised mod name that's safe to use in anchors, attributes, and URLs.</summary> + /// <param name="modName">The mod name.</param> + public string GetSlug(string modName) + { + return this.SlugInvalidCharPattern.Replace(modName, ""); } } } diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index 4efb9f8a..361d01de 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -3,7 +3,9 @@ } @model StardewModdingAPI.Web.ViewModels.IndexModel @section Head { - <link rel="stylesheet" href="~/Content/css/index.css" /> + <link rel="stylesheet" href="~/Content/css/index.css?r=20180615" /> + <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js" crossorigin="anonymous"></script> + <script src="~/Content/js/index.js?r=20180615"></script> } <p id="blurb"> @@ -13,17 +15,29 @@ </p> <div id="call-to-action"> - <a href="@Model.StableVersion.DownloadUrl" class="main-cta">Download SMAPI @Model.StableVersion.Version</a><br /> + <div class="cta-dropdown"> + <a href="@Model.StableVersion.DownloadUrl" class="main-cta download">Download SMAPI @Model.StableVersion.Version</a><br/> + <div class="dropdown-content"> + <a href="https://www.nexusmods.com/stardewvalley/mods/2400"><img src="Content/images/nexus-icon.png" /> Download from Nexus</a> + <a href="@Model.StableVersion.DownloadUrl"><img src="Content/images/direct-download-icon.png" /> Direct download</a> + </div> + </div><br /> + @if (Model.BetaVersion != null) { - <a href="@Model.BetaVersion.DownloadUrl" class="secondary-cta">Download SMAPI @Model.BetaVersion.Version<br /><small>for Stardew Valley 1.3 beta</small></a><br /> + <div class="cta-dropdown secondary-cta-dropdown"> + <a href="@Model.BetaVersion.DownloadUrl" class="secondary-cta download">Download SMAPI @Model.BetaVersion.Version<br/><small>for Stardew Valley 1.3 beta</small></a><br/> + <div class="dropdown-content"> + <a href="https://www.nexusmods.com/stardewvalley/mods/2400"><img src="Content/images/nexus-icon.png" /> Download from Nexus</a> + <a href="@Model.BetaVersion.DownloadUrl"><img src="Content/images/direct-download-icon.png" /> Direct download</a> + </div> + </div><br /> } - <a href="https://stardewvalleywiki.com/Modding:Installing_SMAPI" class="secondary-cta">Install guide</a><br /> - <a href="https://stardewvalleywiki.com/Modding:Player_FAQs" class="secondary-cta">FAQs</a><br /> - <img src="favicon.ico" /> + <a href="https://stardewvalleywiki.com/Modding:Player_Guide" class="secondary-cta">Player guide</a><br /> + <img id="pufferchick" src="Content/images/pufferchick.png" /> </div> -<h2>Get help</h2> +<h2 id="help">Get help</h2> <ul> <li><a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">Mod compatibility list</a></li> <li>Get help <a href="https://stardewvalleywiki.com/Modding:Community#Discord">on Discord</a> or <a href="https://community.playstarbound.com/threads/smapi-stardew-modding-api.108375/">in the forums</a></li> @@ -31,7 +45,7 @@ @if (Model.BetaVersion == null) { - <h2>What's new in SMAPI @Model.StableVersion.Version?</h2> + <h2 id="whatsnew">What's new in SMAPI @Model.StableVersion.Version?</h2> <div class="github-description"> @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) </div> @@ -39,7 +53,7 @@ } else { - <h2>What's new in...</h2> + <h2 id="whatsnew">What's new in...</h2> <h3>SMAPI @Model.StableVersion.Version?</h3> <div class="github-description"> @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) @@ -53,7 +67,7 @@ else <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p> } -<h2>Donate to support SMAPI ♥</h2> +<h2 id="donate">Donate to support SMAPI ♥</h2> <p> SMAPI is an open-source project by Pathoschild. It will always be free, but donations are much appreciated to help pay for development, server hosting, domain fees, coffee, etc. @@ -75,15 +89,23 @@ else Special thanks to acerbicon, <a href="https://www.nexusmods.com/stardewvalley/users/31393530">ChefRude</a>, + cheesysteak, + hawkfalcon, jwdred, - OfficialPiAddict, + KNakamura, + Kono Tyran, + Pucklynn, Robby LaFarge, and a few anonymous users for their ongoing support; you're awesome! 🏅 </p> -<h2>For mod creators</h2> +<h2 id="modcreators">For mod creators</h2> <ul> <li><a href="@Model.StableVersion.DevDownloadUrl">SMAPI @Model.StableVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li> + @if (Model.BetaVersion != null) + { + <li><a href="@Model.BetaVersion.DevDownloadUrl">SMAPI @Model.BetaVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li> + } <li><a href="https://stardewvalleywiki.com/Modding:Index">Modding documentation</a></li> <li>Need help? Come <a href="https://stardewvalleywiki.com/Modding:Community#Discord">chat on Discord</a>.</li> </ul> diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 2d1c1b44..e735e8f3 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -1,76 +1,120 @@ +@using Newtonsoft.Json +@using StardewModdingAPI.Web.Framework.LogParsing.Models +@model StardewModdingAPI.Web.ViewModels.LogParserModel + @{ ViewData["Title"] = "SMAPI log parser"; + IDictionary<string, LogModInfo[]> contentPacks = Model.GetContentPacksByMod(); + IDictionary<string, bool> defaultFilters = Enum + .GetValues(typeof(LogLevel)) + .Cast<LogLevel>() + .ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace); + JsonSerializerSettings noFormatting = new JsonSerializerSettings { Formatting = Formatting.None }; +} - IDictionary<string, LogModInfo[]> contentPacks = Model.ParsedLog?.Mods - ?.GroupBy(mod => mod.ContentPackFor) - .Where(group => group.Key != null) - .ToDictionary(group => group.Key, group => group.ToArray()); - - Regex slugInvalidCharPattern = new Regex("[^a-z0-9]", RegexOptions.Compiled | RegexOptions.IgnoreCase); - string GetSlug(string modName) +@section Head { + @if (Model.PasteID != null) { - return slugInvalidCharPattern.Replace(modName, ""); + <meta name="robots" content="noindex" /> } -} -@using System.Text.RegularExpressions -@using Newtonsoft.Json -@using StardewModdingAPI.Web.Framework.LogParsing.Models -@model StardewModdingAPI.Web.ViewModels.LogParserModel -@section Head { - <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20180225" /> + <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20180627" /> <script src="https://cdn.jsdelivr.net/npm/vue"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js" crossorigin="anonymous"></script> - <script src="~/Content/js/log-parser.js?r=20180225"></script> + <script src="~/Content/js/log-parser.js?r=20180627"></script> <script> $(function() { smapi.logParser({ logStarted: new Date(@Json.Serialize(Model.ParsedLog?.Timestamp)), showPopup: @Json.Serialize(Model.ParsedLog == null), - showMods: @Json.Serialize(Model.ParsedLog?.Mods?.Select(p => GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true), new JsonSerializerSettings { Formatting = Formatting.None }), - showLevels: { - trace: false, - debug: false, - info: true, - alert: true, - warn: true, - error: true - } + showMods: @Json.Serialize(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true), noFormatting), + showLevels: @Json.Serialize(defaultFilters, noFormatting), + enableFilters: @Json.Serialize(!Model.ShowRaw) }, '@Model.SectionUrl'); }); </script> } -@********* -** Intro -*********@ -<p id="blurb">This page lets you upload, view, and share a SMAPI log to help troubleshoot mod issues.</p> - -@if (Model.ParsedLog?.IsValid == true) +@* upload result banner *@ +@if (Model.UploadError != null) { - <div class="banner success" v-pre> - <strong>The log was uploaded successfully!</strong><br/> - Share this URL when asking for help: <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br/> - (Or <a id="upload-button" href="#">upload a new log</a>.) + <div class="banner error" v-pre> + <strong>Oops, the server ran into trouble saving that file.</strong><br /> + <small v-pre>Error details: @Model.UploadError</small> </div> } -else if (Model.ParsedLog?.IsValid == false) +else if (Model.ParseError != null) { <div class="banner error" v-pre> - <strong>Oops, couldn't parse that file. (Make sure you upload the log file, not the console text.)</strong><br /> + <strong>Oops, couldn't parse that log. (Make sure you upload the log file, not the console text.)</strong><br /> Share this URL when asking for help: <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br /> - (Or <a id="upload-button" href="#">upload a new log</a>.)<br /> + (Or <a href="@Model.SectionUrl">upload a new log</a>.)<br /> <br /> - <small v-pre>Error details: @Model.ParsedLog.Error</small> + <small v-pre>Error details: @Model.ParseError</small> </div> } -else +else if (Model.ParsedLog?.IsValid == true) { - <input type="button" id="upload-button" value="Share a new log" /> + <div class="banner success" v-pre> + <strong>Share this link to let someone else see the log:</strong> <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br /> + (Or <a href="@Model.SectionUrl">upload a new log</a>.) + </div> } -@********* -** Parsed log -*********@ +@* upload new log *@ +@if (Model.ParsedLog == null) +{ + <h2>Where do I find my SMAPI log?</h2> + <div>What system do you use?</div> + <ul id="os-list"> + <li><input type="radio" name="os" value="linux" id="os-linux" /> <label for="os-linux">Linux</label></li> + <li><input type="radio" name="os" value="mac" id="os-mac" /> <label for="os-mac">Mac</label></li> + <li><input type="radio" name="os" value="windows" id="os-windows" /> <label for="os-windows">Windows</label></li> + </ul> + <div data-os="linux"> + On Linux: + <ol> + <li>Open the Files app.</li> + <li>Click the options menu (might be labeled <em>Go</em> or <code>⋮</code>).</li> + <li>Choose <em>Enter Location</em>.</li> + <li>Enter this exact text: <pre>~/.config/StardewValley/ErrorLogs</pre></li> + <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> + </ol> + </div> + <div data-os="mac"> + On Mac: + <ol> + <li>Open the Finder app.</li> + <li>Click <em>Go</em> at the top, then <em>Enter Location</em>.</li> + <li>Enter this exact text: <pre>~/.config/StardewValley/ErrorLogs</pre></li> + <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> + </ol> + </div> + <div data-os="windows"> + On Windows: + <ol> + <li>Press the <code>Windows</code> and <code>R</code> buttons at the same time.</li> + <li>In the 'run' box that appears, enter this exact text: <pre>%appdata%\StardewValley\ErrorLogs</pre></li> + <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> + </ol> + </div> + + <h2>How do I share my log?</h2> + <form action="@Model.SectionUrl" method="post"> + <ol> + <li> + Drag the file onto this textbox (or paste the text in):<br /> + <textarea id="input" name="input" placeholder="paste log here"></textarea> + </li> + <li> + Click this button:<br /> + <input type="submit" id="submit" value="save log" /> + </li> + <li>On the new page, copy the URL and send it to the person helping you.</li> + </ol> + </form> +} + +@* parsed log *@ @if (Model.ParsedLog?.IsValid == true) { <h2>Log info</h2> @@ -95,17 +139,20 @@ else </tr> </table> <br /> - <table id="mods"> + <table id="mods" class="@(Model.ShowRaw ? "filters-disabled" : null)"> <caption> Installed mods: - <span class="notice txt"><i>click any mod to filter</i></span> - <span class="notice btn txt" v-on:click="showAllMods" v-show="stats.modsHidden > 0">show all</span> - <span class="notice btn txt" v-on:click="hideAllMods" v-show="stats.modsShown > 0 && stats.modsHidden > 0">hide all</span> + @if (!Model.ShowRaw) + { + <span class="notice txt"><i>click any mod to filter</i></span> + <span class="notice btn txt" v-on:click="showAllMods" v-show="stats.modsHidden > 0">show all</span> + <span class="notice btn txt" v-on:click="hideAllMods" v-show="stats.modsShown > 0 && stats.modsHidden > 0">hide all</span> + } </caption> @foreach (var mod in Model.ParsedLog.Mods.Where(p => p.ContentPackFor == null)) { - <tr v-on:click="toggleMod('@GetSlug(mod.Name)')" class="mod-entry" v-bind:class="{ hidden: !showMods['@GetSlug(mod.Name)'] }"> - <td><input type="checkbox" v-bind:checked="showMods['@GetSlug(mod.Name)']" v-show="anyModsHidden" /></td> + <tr v-on:click="toggleMod('@Model.GetSlug(mod.Name)')" class="mod-entry" v-bind:class="{ hidden: !showMods['@Model.GetSlug(mod.Name)'] }"> + <td><input type="checkbox" v-bind:checked="showMods['@Model.GetSlug(mod.Name)']" v-show="anyModsHidden" /></td> <td v-pre> <strong>@mod.Name</strong> @mod.Version @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList)) @@ -134,36 +181,47 @@ else </tr> } </table> - <div id="filters"> - Filter messages: - <span v-bind:class="{ active: showLevels['trace'] }" v-on:click="toggleLevel('trace')">TRACE</span> | - <span v-bind:class="{ active: showLevels['debug'] }" v-on:click="toggleLevel('debug')">DEBUG</span> | - <span v-bind:class="{ active: showLevels['info'] }" v-on:click="toggleLevel('info')">INFO</span> | - <span v-bind:class="{ active: showLevels['alert'] }" v-on:click="toggleLevel('alert')">ALERT</span> | - <span v-bind:class="{ active: showLevels['warn'] }" v-on:click="toggleLevel('warn')">WARN</span> | - <span v-bind:class="{ active: showLevels['error'] }" v-on:click="toggleLevel('error')">ERROR</span> - </div> - <table id="log"> - @foreach (var message in Model.ParsedLog.Messages) - { - string levelStr = message.Level.ToString().ToLower(); + @if (!Model.ShowRaw) + { + <div id="filters"> + Filter messages: + <span v-bind:class="{ active: showLevels['trace'] }" v-on:click="toggleLevel('trace')">TRACE</span> | + <span v-bind:class="{ active: showLevels['debug'] }" v-on:click="toggleLevel('debug')">DEBUG</span> | + <span v-bind:class="{ active: showLevels['info'] }" v-on:click="toggleLevel('info')">INFO</span> | + <span v-bind:class="{ active: showLevels['alert'] }" v-on:click="toggleLevel('alert')">ALERT</span> | + <span v-bind:class="{ active: showLevels['warn'] }" v-on:click="toggleLevel('warn')">WARN</span> | + <span v-bind:class="{ active: showLevels['error'] }" v-on:click="toggleLevel('error')">ERROR</span> + </div> - <tr class="@levelStr mod" v-show="filtersAllow('@GetSlug(message.Mod)', '@levelStr')"> - <td v-pre>@message.Time</td> - <td v-pre>@message.Level.ToString().ToUpper()</td> - <td v-pre data-title="@message.Mod">@message.Mod</td> - <td v-pre>@message.Text</td> - </tr> - if (message.Repeated > 0) + <table id="log"> + @foreach (var message in Model.ParsedLog.Messages) { - <tr class="@levelStr mod mod-repeat" v-show="filtersAllow('@GetSlug(message.Mod)', '@levelStr')"> - <td colspan="3"></td> - <td v-pre><i>repeats [@message.Repeated] times.</i></td> + string levelStr = message.Level.ToString().ToLower(); + + <tr class="@levelStr mod" v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr')"> + <td v-pre>@message.Time</td> + <td v-pre>@message.Level.ToString().ToUpper()</td> + <td v-pre data-title="@message.Mod">@message.Mod</td> + <td v-pre>@message.Text</td> </tr> + if (message.Repeated > 0) + { + <tr class="@levelStr mod mod-repeat" v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr')"> + <td colspan="3"></td> + <td v-pre><i>repeats [@message.Repeated] times.</i></td> + </tr> + } } - } - </table> + </table> + + <small><a href="@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))?raw=true">view raw log</a></small> + } + else + { + <pre v-pre>@Model.ParsedLog.RawText</pre> + <small><a href="@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))">view parsed log</a></small> + } </div> } else if (Model.ParsedLog?.IsValid == false) @@ -171,22 +229,3 @@ else if (Model.ParsedLog?.IsValid == false) <h3>Raw log</h3> <pre v-pre>@Model.ParsedLog.RawText</pre> } - -<div id="upload-area"> - <div id="popup-upload" class="popup"> - <h1>Upload log file</h1> - <div class="frame"> - <ol> - <li><a href="https://stardewvalleywiki.com/Modding:Player_FAQs#SMAPI_log" target="_blank">Find your SMAPI log file</a> (not the console text).</li> - <li>Drag the file onto the textbox below (or paste the text in).</li> - <li>Click <em>Parse</em>.</li> - </ol> - <textarea id="input" placeholder="Paste or drag the log here"></textarea> - <div class="buttons"> - <input type="button" id="submit" value="Parse" /> - <input type="button" id="cancel" value="Cancel" /> - </div> - </div> - </div> - <div id="uploader"></div> -</div> diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index ac98c71b..29da9100 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -1,11 +1,12 @@ @using Microsoft.Extensions.Options @using StardewModdingAPI.Web.Framework.ConfigModels -@inject IOptions<ContextConfig> ContextConfig +@inject IOptions<SiteConfig> SiteConfig <!DOCTYPE html> <html> <head> <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1"> <title>@ViewData["Title"] - SMAPI.io</title> <link rel="stylesheet" href="~/Content/css/main.css" /> @RenderSection("Head", required: false) @@ -14,8 +15,8 @@ <div id="sidebar"> <h4>SMAPI</h4> <ul> - <li><a href="@ContextConfig.Value.RootUrl">About SMAPI</a></li> - <li><a href="@ContextConfig.Value.LogParserUrl">Log parser</a></li> + <li><a href="@SiteConfig.Value.RootUrl">About SMAPI</a></li> + <li><a href="@SiteConfig.Value.LogParserUrl">Log parser</a></li> <li><a href="https://stardewvalleywiki.com/Modding:Index">Docs</a></li> </ul> </div> diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 495af120..67bb7748 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -16,10 +16,13 @@ "Microsoft": "Information" } }, - "Context": { + + "Site": { "RootUrl": "http://localhost:59482/", - "LogParserUrl": "http://localhost:59482/log/" + "LogParserUrl": "http://localhost:59482/log/", + "EnableSmapiBeta": false }, + "ApiClients": { "GitHubUsername": null, "GitHubPassword": null, diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 095707a8..9e3270ae 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -13,10 +13,13 @@ "Default": "Warning" } }, - "Context": { - "RootUrl": null, // see top note - "LogParserUrl": null // see top note + + "Site": { + "RootUrl": null, // see top note + "LogParserUrl": null, // see top note + "EnableSmapiBeta": null // see top note }, + "ApiClients": { "UserAgent": "SMAPI/{0} (+https://smapi.io)", @@ -30,9 +33,9 @@ "GitHubUsername": null, // see top note "GitHubPassword": null, // see top note - "NexusUserAgent": "Nexus Client v0.63.15", - "NexusBaseUrl": "http://www.nexusmods.com/stardewvalley", + "NexusBaseUrl": "https://www.nexusmods.com/stardewvalley/", "NexusModUrlFormat": "mods/{0}", + "NexusModScrapeUrlFormat": "mods/{0}?tab=files", "PastebinBaseUrl": "https://pastebin.com/", "PastebinUserKey": null, // see top note @@ -46,6 +49,8 @@ "ChucklefishKey": "Chucklefish", "GitHubKey": "GitHub", - "NexusKey": "Nexus" + "NexusKey": "Nexus", + + "WikiCompatibilityPageUrl": "https://smapi.io/compat" } } diff --git a/src/SMAPI.Web/wwwroot/Content/css/index.css b/src/SMAPI.Web/wwwroot/Content/css/index.css index 06cd6fb4..514e1a5c 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/index.css +++ b/src/SMAPI.Web/wwwroot/Content/css/index.css @@ -18,7 +18,8 @@ h1 { text-align: center; } -#call-to-action a { +#call-to-action a.main-cta, +#call-to-action a.secondary-cta { box-shadow: #caefab 0 1px 0 0 inset; background: linear-gradient(#77d42a 5%, #5cb811 100%) #77d42a; border-radius: 6px; @@ -40,6 +41,58 @@ h1 { text-shadow: #2b665e 0 1px 0; } +.cta-dropdown { + position: relative; + display: inline-block; + margin-bottom: 1em; +} + +.cta-dropdown a.download { + margin-bottom: 0 !important; +} + +.cta-dropdown .dropdown-content { + display: none; + position: absolute; + text-align: left; + box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2); + border: 1px solid #566963; + background: #5cb811; + border-top: 0; + border-radius: 0 0 6px 6px; + margin-top: -6px; + z-index: 1; +} + +.cta-dropdown .dropdown-content a:hover { + background-color: #ddd; +} + +.cta-dropdown .dropdown-content img { + width: 0.85em; + height: 0.85em; +} + +.cta-dropdown.secondary-cta-dropdown .dropdown-content a:hover { + background-color: #566963; +} + +.cta-dropdown.secondary-cta-dropdown .dropdown-content { + background-color: #768d87; + border-color: #566963; +} + +.cta-dropdown.secondary-cta-dropdown .dropdown-content a { + color: #fff; + text-shadow: #2b665e 0 1px 0; +} + +.cta-dropdown .dropdown-content a { + padding: 0.75em 1em; + text-decoration: none; + display: block; +} + /********* ** Subsections *********/ @@ -48,10 +101,6 @@ h1 { padding-left: 1em; } -.github-description .noinclude { - display: none; -} - #support-links li small { display: block; width: 50em; diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index 789274e2..1fcd1bff 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -1,12 +1,8 @@ /********* ** Main layout *********/ -input[type="button"] { - font-size: 20px; - border-radius: 5px; - outline: none; - box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2); - cursor: pointer; +#content { + max-width: 100%; } caption { @@ -20,15 +16,6 @@ caption { font-family: monospace; } -input#upload-button { - background: #ccf; - border: 1px solid #000088; -} - -input#upload-button { - background: #eef; -} - table caption { font-weight: bold; } @@ -39,6 +26,7 @@ table caption { .banner { border: 2px solid gray; border-radius: 5px; + margin-top: 1em; padding: 1em; } @@ -91,6 +79,10 @@ table#metadata, table#mods { cursor: pointer; } +#mods.filters-disabled tr { + cursor: default; +} + #metadata tr, #mods tr { background: #eee @@ -200,6 +192,12 @@ table#metadata, table#mods { color: #f00; } +#log .critical { + background-color: #c00; + color: #fff; + font-weight: bold; +} + #log { border-spacing: 0; } @@ -256,88 +254,19 @@ table#metadata, table#mods { /********* -** Upload popup +** Upload form *********/ -#upload-area .popup, -#upload-area #uploader { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, .33); - z-index: 2; - display: none; - padding: 5px; -} - -#upload-area #uploader:after { - content: attr(data-text); - display: block; - width: 100px; - height: 24px; - line-height: 25px; - border: 1px solid #000; - background: #fff; - position: absolute; - top: 50%; - left: 50%; - margin: -12px -50px 0 0; - font-size: 18px; - font-weight: bold; - text-align: center; - border-radius: 5px; -} - -#upload-area .popup h1 { - position: absolute; - top: 10%; - left: 50%; - margin-left: -150px; - text-align: center; - width: 300px; - border: 1px solid #008; - border-radius: 5px; - background: #fff; - font-family: sans-serif; - font-size: 40px; - margin-top: -25px; - z-index: 10; - border-bottom: 0; +#os-list { + list-style: none; } -#upload-area .frame { - margin: auto; - margin-top: 25px; - padding: 2em; - position: absolute; - top: 10%; - left: 10%; - right: 10%; - bottom: 10%; - padding-bottom: 30px; - background: #FFF; - border-radius: 5px; - border: 1px solid #008; -} - -#upload-area #cancel { - border: 1px solid #880000; - background-color: #fcc; -} - -#upload-area #submit { - border: 1px solid #008800; - background-color: #cfc; -} - -#upload-area #submit:hover { - background-color: #efe; +div[data-os] { + display: none; } -#upload-area #input { +#input { width: 100%; - height: 30em; + height: 20em; max-height: 70%; margin: auto; box-sizing: border-box; @@ -346,3 +275,13 @@ table#metadata, table#mods { outline: none; box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2); } + +#submit { + font-size: 1.5em; + border-radius: 5px; + outline: none; + box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2); + cursor: pointer; + border: 1px solid #008800; + background-color: #cfc; +} diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css index d1fa49e0..57eeee88 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/main.css +++ b/src/SMAPI.Web/wwwroot/Content/css/main.css @@ -28,7 +28,8 @@ h2 { h3 { font-size: 1.2em; border-bottom: 1px solid #AAA; - width: 50%; + width: 55em; + max-width: 100%; } a { @@ -44,6 +45,8 @@ a { #content { min-height: 140px; + width: calc(100% - 2em); + max-width: 60em; padding: 0 1em 1em 1em; border-left: 1px solid #CCC; background: #FFF; @@ -51,7 +54,7 @@ a { } #content p { - max-width: 55em; + max-width: 100%; } .section { @@ -105,3 +108,34 @@ a { #footer a { color: #669; } + +/* mobile fixes */ +@media (min-width: 1020px) and (max-width: 1199px) { + #sidebar { + width: 7em; + background: none; + } + + #content-column { + left: 7em; + } +} + +@media (max-width: 1019px) { + h1 { + margin-top: 0; + } + + #sidebar { + margin-top: 0; + width: auto; + min-height: 0; + background: none; + } + + #content-column { + position: inherit; + top: inherit; + left: inherit; + } +} diff --git a/src/SMAPI.Web/wwwroot/Content/images/direct-download-icon.png b/src/SMAPI.Web/wwwroot/Content/images/direct-download-icon.png Binary files differnew file mode 100644 index 00000000..6c30ca36 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/images/direct-download-icon.png diff --git a/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.png b/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.png Binary files differnew file mode 100644 index 00000000..10c66712 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/images/nexus-icon.png diff --git a/src/SMAPI.Web/wwwroot/Content/images/pufferchick-cool.png b/src/SMAPI.Web/wwwroot/Content/images/pufferchick-cool.png Binary files differnew file mode 100644 index 00000000..f359146c --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/images/pufferchick-cool.png diff --git a/src/SMAPI.Web/wwwroot/Content/images/pufferchick.png b/src/SMAPI.Web/wwwroot/Content/images/pufferchick.png Binary files differnew file mode 100644 index 00000000..1de9cf47 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/images/pufferchick.png diff --git a/src/SMAPI.Web/wwwroot/Content/js/index.js b/src/SMAPI.Web/wwwroot/Content/js/index.js new file mode 100644 index 00000000..d0734b02 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/js/index.js @@ -0,0 +1,34 @@ +$(document).ready(function () { + /* enable pufferchick */ + var pufferchick = $("#pufferchick"); + $(".cta-dropdown").hover( + function () { + pufferchick.attr("src", "Content/images/pufferchick-cool.png"); + }, + function () { + pufferchick.attr("src", "Content/images/pufferchick.png"); + } + ); + + /* enable download dropdowns */ + $(".cta-dropdown a.download").each(function(i, button) { + button = $(button); + var wrapper = button.parent(".cta-dropdown"); + var button = wrapper.find(".download"); + var dropdownContent = wrapper.find(".dropdown-content"); + + $(window).on("click", function(e) { + var target = $(e.target); + + // toggle dropdown on button click + if (target.is(button) || $.contains(button.get(0), target.get(0))) { + e.preventDefault(); + dropdownContent.toggle(); + } + + // else hide dropdown + else + dropdownContent.hide(); + }); + }); +}); diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index c4a35e96..0c654205 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -39,11 +39,17 @@ smapi.logParser = function (data, sectionUrl) { } }, methods: { - toggleLevel: function(id) { + toggleLevel: function (id) { + if (!data.enableFilters) + return; + this.showLevels[id] = !this.showLevels[id]; }, toggleMod: function (id) { + if (!data.enableFilters) + return; + var curShown = this.showMods[id]; // first filter: only show this by default @@ -64,6 +70,9 @@ smapi.logParser = function (data, sectionUrl) { }, showAllMods: function () { + if (!data.enableFilters) + return; + for (var key in this.showMods) { if (this.showMods.hasOwnProperty(key)) { this.showMods[key] = true; @@ -73,6 +82,9 @@ smapi.logParser = function (data, sectionUrl) { }, hideAllMods: function () { + if (!data.enableFilters) + return; + for (var key in this.showMods) { if (this.showMods.hasOwnProperty(key)) { this.showMods[key] = false; @@ -90,88 +102,50 @@ smapi.logParser = function (data, sectionUrl) { /********** ** Upload form *********/ - var error = $("#error"); - - $("#upload-button").on("click", function(e) { - e.preventDefault(); - - $("#input").val(""); - $("#popup-upload").fadeIn(); - }); - - var closeUploadPopUp = function() { - $("#popup-upload").fadeOut(400); - }; - - $("#popup-upload").on({ - 'dragover dragenter': function(e) { - e.preventDefault(); - e.stopPropagation(); - }, - 'drop': function(e) { - $("#uploader").attr("data-text", "Reading..."); - $("#uploader").show(); - var dataTransfer = e.originalEvent.dataTransfer; - if (dataTransfer && dataTransfer.files.length) { - e.preventDefault(); - e.stopPropagation(); - var file = dataTransfer.files[0]; - var reader = new FileReader(); - reader.onload = $.proxy(function(file, $input, event) { - $input.val(event.target.result); - $("#uploader").fadeOut(); - $("#submit").click(); - }, this, file, $("#input")); - reader.readAsText(file); - } - }, - 'click': function(e) { - if (e.target.id === "popup-upload") - closeUploadPopUp(); + var input = $("#input"); + if (input.length) { + // get elements + var systemOptions = $("input[name='os']"); + var systemInstructions = $("div[data-os]"); + var submit = $("#submit"); + + // instruction OS chooser + var chooseSystem = function() { + systemInstructions.hide(); + systemInstructions.filter("[data-os='" + $("input[name='os']:checked").val() + "']").show(); } - }); - - $("#submit").on("click", function() { - $("#popup-upload").fadeOut(); - var paste = $("#input").val(); - if (paste) { - //memory = ""; - $("#uploader").attr("data-text", "Saving..."); - $("#uploader").fadeIn(); - $ - .ajax({ - type: "POST", - url: sectionUrl + "/save", - data: JSON.stringify(paste), - contentType: "application/json" // sent to API - }) - .fail(function(xhr, textStatus) { - $("#uploader").fadeOut(); - error.html('<h1>Parsing failed!</h1>Parsing of the log failed, details follow.<br /> <p>Stage: Upload</p>Error: ' + textStatus + ': ' + xhr.responseText + "<hr /><pre>" + $("#input").val() + "</pre>"); - }) - .then(function(data) { - $("#uploader").fadeOut(); - if (!data.success) - error.html('<h1>Parsing failed!</h1>Parsing of the log failed, details follow.<br /> <p>Stage: Upload</p>Error: ' + data.error + "<hr /><pre>" + $("#input").val() + "</pre>"); - else - location.href = (sectionUrl.replace(/\/$/, "") + "/" + data.id); - }); - } else { - alert("Unable to parse log, the input is empty!"); - $("#uploader").fadeOut(); + systemOptions.on("click", chooseSystem); + chooseSystem(); + + // disable submit if it's empty + var toggleSubmit = function() + { + var hasText = !!input.val().trim(); + submit.prop("disabled", !hasText); } - }); + input.on("input", toggleSubmit); + toggleSubmit(); - $(document).on("keydown", function(e) { - if (e.which === 27) { - if ($("#popup-upload").css("display") !== "none" && $("#popup-upload").css("opacity") === 1) { - closeUploadPopUp(); + // drag & drop file + input.on({ + 'dragover dragenter': function(e) { + e.preventDefault(); + e.stopPropagation(); + }, + 'drop': function(e) { + var dataTransfer = e.originalEvent.dataTransfer; + if (dataTransfer && dataTransfer.files.length) { + e.preventDefault(); + e.stopPropagation(); + var file = dataTransfer.files[0]; + var reader = new FileReader(); + reader.onload = $.proxy(function(file, $input, event) { + $input.val(event.target.result); + toggleSubmit(); + }, this, file, $("#input")); + reader.readAsText(file); + } } - } - }); - $("#cancel").on("click", closeUploadPopUp); - - if (data.showPopup) - $("#popup-upload").fadeIn(); - + }); + } }; diff --git a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json b/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json new file mode 100644 index 00000000..e72efb39 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json @@ -0,0 +1,1676 @@ +{ + /** + * Metadata about some SMAPI mods used in compatibility, update, and dependency checks. This + * field shouldn't be edited by players in most cases. + * + * Standard fields + * =============== + * The predefined fields are documented below (only 'ID' is required). Each entry's key is the + * default display name for the mod if one isn't available (e.g. in dependency checks). + * + * - ID: the mod's latest unique ID (if any). + * + * - FormerIDs: uniquely identifies the mod across multiple versions, and supports matching + * other fields if no ID was specified. This doesn't include the latest ID, if any. Multiple + * variants can be separated with '|'. + * + * - MapLocalVersions and MapRemoteVersions correct local manifest versions and remote versions + * during update checks. For example, if the API returns version '1.1-1078' where '1078' is + * intended to be a build number, MapRemoteVersions can map it to '1.1' when comparing to the + * mod's current version. This is only meant to support legacy mods with injected update keys. + * + * Versioned metadata + * ================== + * Each record can also specify extra metadata using the field keys below. + * + * Each key consists of a field name prefixed with any combination of version range and 'Default', + * separated by pipes (whitespace trimmed). For example, 'UpdateKey' will always override, + * 'Default | UpdateKey' will only override if the mod has no update keys, and + * '~1.1 | Default | Name' will do the same up to version 1.1. + * + * The version format is 'min~max' (where either side can be blank for unbounded), or a single + * version number. + * + * These are the valid field names: + * + * - UpdateKey: the update key to set in the mod's manifest. This is used to enable update + * checks for older mods that haven't been updated to use it yet. + * + * - Status: overrides compatibility checks. The possible values are Obsolete (SMAPI won't load + * it because the mod should no longer be used), AssumeBroken (SMAPI won't load it because + * the specified version isn't compatible), or AssumeCompatible (SMAPI will try to load it + * even if it detects incompatible code). + * + * Note that this shouldn't be set to 'AssumeBroken' if SMAPI can detect the incompatibility + * automatically, since that hides the details from trace logs. + * + * - StatusReasonPhrase: a message to show to the player explaining why the mod can't be loaded + * (if applicable). If blank, will default to a generic not-compatible message. + * + * - AlternativeUrl: a URL where the player can find an unofficial update or alternative if the + * mod is no longer compatible. + */ + "ModData": { + "AccessChestAnywhere": { + "ID": "AccessChestAnywhere", + "MapLocalVersions": { "1.1-1078": "1.1" }, + "Default | UpdateKey": "Nexus:257", + "~1.1 | Status": "AssumeBroken" + }, + + "Adjust Artisan Prices": { + "ID": "ThatNorthernMonkey.AdjustArtisanPrices", + "FormerIDs": "1e36d4ca-c7ef-4dfb-9927-d27a6c3c8bdc", // changed in 0.0.2-pathoschild-update + "MapRemoteVersions": { "0.01": "0.0.1" }, + "Default | UpdateKey": "Chucklefish:3532" + }, + + "Adjust Monster": { + "ID": "mmanlapat.AdjustMonster", + "Default | UpdateKey": "Nexus:1161" + }, + + "Advanced Location Loader": { + "ID": "Entoarox.AdvancedLocationLoader", + "~1.3.7 | UpdateKey": "Chucklefish:3619" // only enable update checks up to 1.3.7 by request (has its own update-check feature) + }, + + "Adventure Shop Inventory": { + "ID": "HammurabiAdventureShopInventory", + "Default | UpdateKey": "Chucklefish:4608" + }, + + "AgingMod": { + "ID": "skn.AgingMod", + "Default | UpdateKey": "Nexus:1129", + "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "All Crops All Seasons": { + "ID": "cantorsdust.AllCropsAllSeasons", + "FormerIDs": "29ee8246-d67b-4242-a340-35a9ae0d5dd7 | community.AllCropsAllSeasons", // changed in 1.3 and 1.5 + "Default | UpdateKey": "Nexus:170" + }, + + "All Professions": { + "ID": "cantorsdust.AllProfessions", + "FormerIDs": "8c37b1a7-4bfb-4916-9d8a-9533e6363ea3 | community.AllProfessions", // changed in 1.2 and 1.3.1 + "Default | UpdateKey": "Nexus:174" + }, + + "Almighty Farming Tool": { + "ID": "439", + "MapRemoteVersions": { + "1.21": "1.2.1", + "1.22-unofficial.3.mizzion": "1.2.2-unofficial.3.mizzion" + }, + "Default | UpdateKey": "Nexus:439" + }, + + "Animal Husbandry": { + "ID": "DIGUS.ANIMALHUSBANDRYMOD", + "FormerIDs": "DIGUS.BUTCHER", // changed in 2.0.1 + "Default | UpdateKey": "Nexus:1538" + }, + + "Animal Mood Fix": { + "ID": "GPeters-AnimalMoodFix", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2." + }, + + "Animal Sitter": { + "ID": "jwdred.AnimalSitter", + "Default | UpdateKey": "Nexus:581", + "~1.0.8 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Arcade Pong": { + "ID": "Platonymous.ArcadePong", + "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.16 due to reflection into SMAPI internals + }, + + "A Tapper's Dream": { + "ID": "ddde5195-8f85-4061-90cc-0d4fd5459358", + "Default | UpdateKey": "Nexus:260" + }, + + "Auto Animal Doors": { + "ID": "AaronTaggart.AutoAnimalDoors", + "Default | UpdateKey": "Nexus:1019" + }, + + "Auto-Eat": { + "ID": "Permamiss.AutoEat", + "FormerIDs": "BALANCEMOD_AutoEat", // changed in 1.1.1 + "Default | UpdateKey": "Nexus:643" + }, + + "AutoFish": { + "ID": "WhiteMind.AF", + "Default | UpdateKey": "Nexus:1895" + }, + + "AutoGate": { + "ID": "AutoGate", + "Default | UpdateKey": "Nexus:820" + }, + + "Automate": { + "ID": "Pathoschild.Automate", + "Default | UpdateKey": "Nexus:1063", + "~1.10-beta.7 | Status": "AssumeBroken" // broke in SDV 1.3.20 + }, + + "Automated Doors": { + "ID": "azah.automated-doors", + "FormerIDs": "1abcfa07-2cf4-4dc3-a6e9-6068b642112b", // changed in 1.4.1 + "Default | UpdateKey": "GitHub:azah/AutomatedDoors" // added in 1.4.2 + }, + + "AutoSpeed": { + "ID": "Omegasis.AutoSpeed", + "Default | UpdateKey": "Nexus:443" // added in 1.4.1 + }, + + "Basic Sprinklers Improved": { + "ID": "lrsk_sdvm_bsi.0117171308", + "MapRemoteVersions": { "1.0.2": "1.0.1-release" }, // manifest not updated + "Default | UpdateKey": "Nexus:833" + }, + + "Better Hay": { + "ID": "cat.betterhay", + "Default | UpdateKey": "Nexus:1430" + }, + + "Better Quality More Seasons": { + "ID": "SB_BQMS", + "Default | UpdateKey": "Nexus:935" + }, + + "Better Quarry": { + "ID": "BetterQuarry", + "Default | UpdateKey": "Nexus:771" + }, + + "Better Ranching": { + "ID": "BetterRanching", + "Default | UpdateKey": "Nexus:859" + }, + + "Better Shipping Box": { + "ID": "Kithio:BetterShippingBox", + "MapLocalVersions": { "1.0.1": "1.0.2" }, + "Default | UpdateKey": "Chucklefish:4302" + }, + + "Better Sprinklers": { + "ID": "Speeder.BetterSprinklers", + "FormerIDs": "SPDSprinklersMod", // changed in 2.3 + "Default | UpdateKey": "Nexus:41" + }, + + "Billboard Anywhere": { + "ID": "Omegasis.BillboardAnywhere", + "Default | UpdateKey": "Nexus:492" // added in 1.4.1 + }, + + "Birthday Mail": { + "ID": "KathrynHazuka.BirthdayMail", + "FormerIDs": "005e02dc-d900-425c-9c68-1ff55c5a295d", // changed in 1.2.3-pathoschild-update + "Default | UpdateKey": "Nexus:276", + "MapRemoteVersions": { "1.3.1": "1.3" } // manifest not updated + }, + + "Breed Like Rabbits": { + "ID": "dycedarger.breedlikerabbits", + "Default | UpdateKey": "Nexus:948" + }, + + "Build Endurance": { + "ID": "Omegasis.BuildEndurance", + "Default | UpdateKey": "Nexus:445" // added in 1.4.1 + }, + + "Build Health": { + "ID": "Omegasis.BuildHealth", + "Default | UpdateKey": "Nexus:446" // added in 1.4.1 + }, + + "Buy Cooking Recipes": { + "ID": "Denifia.BuyRecipes", + "Default | UpdateKey": "Nexus:1126" // added in 1.0.1 (2017-10-04) + }, + + "Buy Back Collectables": { + "ID": "Omegasis.BuyBackCollectables", + "FormerIDs": "BuyBackCollectables", // changed in 1.4 + "Default | UpdateKey": "Nexus:507" // added in 1.4.1 + }, + + "Carry Chest": { + "ID": "spacechase0.CarryChest", + "Default | UpdateKey": "Nexus:1333" + }, + + "Casks Anywhere": { + "ID": "CasksAnywhere", + "MapLocalVersions": { "1.1-alpha": "1.1" }, + "Default | UpdateKey": "Nexus:878" + }, + + "Categorize Chests": { + "ID": "CategorizeChests", + "Default | UpdateKey": "Nexus:1300", + "~1.4.3-unofficial.2.mizzion | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.18 (in-game errors) + }, + + "Chefs Closet": { + "ID": "Duder.ChefsCloset", + "MapLocalVersions": { "1.3-1": "1.3" }, + "Default | UpdateKey": "Nexus:1030" + }, + + "Chest Label System": { + "ID": "Speeder.ChestLabel", + "FormerIDs": "SPDChestLabel", // changed in 1.5.1-pathoschild-update + "Default | UpdateKey": "Nexus:242" + }, + + "Chest Pooling": { + "ID": "mralbobo.ChestPooling", + "Default | UpdateKey": "GitHub:mralbobo/stardew-chest-pooling" + }, + + "Chests Anywhere": { + "ID": "Pathoschild.ChestsAnywhere", + "FormerIDs": "ChestsAnywhere", // changed in 1.9 + "Default | UpdateKey": "Nexus:518", + "~1.12.4 | Status": "AssumeBroken" // broke in SDV 1.3 + }, + + "CJB Automation": { + "ID": "CJBAutomation", + "Default | UpdateKey": "Nexus:211", + "~1.4 | Status": "AssumeBroken", // broke in SDV 1.2 + "~1.4 | AlternativeUrl": "http://www.nexusmods.com/stardewvalley/mods/1063" + }, + + "CJB Cheats Menu": { + "ID": "CJBok.CheatsMenu", + "FormerIDs": "CJBCheatsMenu", // changed in 1.14 + "Default | UpdateKey": "Nexus:4", + "~1.18-beta | Status": "AssumeBroken" // broke in SDV 1.3, first beta causes significant friendship bugs + }, + + "CJB Item Spawner": { + "ID": "CJBok.ItemSpawner", + "FormerIDs": "CJBItemSpawner", // changed in 1.7 + "Default | UpdateKey": "Nexus:93", + "~1.10 | Status": "AssumeBroken" // broke in SDV 1.3 + }, + + "CJB Show Item Sell Price": { + "ID": "CJBok.ShowItemSellPrice", + "FormerIDs": "CJBShowItemSellPrice", // changed in 1.7 + "Default | UpdateKey": "Nexus:5", + "~1.8 | Status": "AssumeBroken" // broke in SDV 1.3 + }, + + "Clean Farm": { + "ID": "tstaples.CleanFarm", + "Default | UpdateKey": "Nexus:794" + }, + + "Climates of Ferngill": { + "ID": "KoihimeNakamura.ClimatesOfFerngill", + "Default | UpdateKey": "Nexus:604" + }, + + "Coal Regen": { + "ID": "Blucifer.CoalRegen", + "Default | UpdateKey": "Nexus:1664" + }, + + "Cobalt": { + "ID": "spacechase0.Cobalt", + "MapRemoteVersions": { "1.1.3": "1.1.2" } // not updated in manifest + }, + + "Cold Weather Haley": { + "ID": "LordXamon.ColdWeatherHaleyPRO", + "Default | UpdateKey": "Nexus:1169", + "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Colored Chests": { + "ID": "4befde5c-731c-4853-8e4b-c5cdf946805f", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "colored chests were added in Stardew Valley 1.1." + }, + + "Combat with Farm Implements": { + "ID": "SPDFarmingImplementsInCombat", + "Default | UpdateKey": "Nexus:313" + }, + + "Community Bundle Item Tooltip": { + "ID": "musbah.bundleTooltip", + "Default | UpdateKey": "Nexus:1329" + }, + + "Concentration on Farming": { + "ID": "punyo.ConcentrationOnFarming", + "Default | UpdateKey": "Nexus:1445" + }, + + "Configurable Machines": { + "ID": "21da6619-dc03-4660-9794-8e5b498f5b97", + "MapLocalVersions": { "1.2-beta": "1.2" }, + "Default | UpdateKey": "Nexus:280" + }, + + "Configurable Shipping Dates": { + "ID": "ConfigurableShippingDates", + "Default | UpdateKey": "Nexus:675", + "~1.1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Content Patcher": { + "ID": "Pathoschild.ContentPatcher", + "Default | UpdateKey": "Nexus:1915", + "~1.4-beta.5 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.18 (in-game errors) + }, + + "Cooking Skill": { + "ID": "spacechase0.CookingSkill", + "FormerIDs": "CookingSkill", // changed in 1.0.4–6 + "Default | UpdateKey": "Nexus:522" + }, + + "CrabNet": { + "ID": "jwdred.CrabNet", + "Default | UpdateKey": "Nexus:584" + }, + + "Crafting Counter": { + "ID": "lolpcgaming.CraftingCounter", + "Default | UpdateKey": "Nexus:1585", + "MapRemoteVersions": { "1.1": "1.0" } // not updated in manifest + }, + + "Current Location": { + "ID": "CurrentLocation102120161203", + "Default | UpdateKey": "Nexus:638" + }, + + "Custom Asset Modifier": { + "ID": "Omegasis.CustomAssetModifier", + "Default | UpdateKey": "1836" + }, + + "Custom Critters": { + "ID": "spacechase0.CustomCritters", + "Default | UpdateKey": "Nexus:1255" + }, + + "Custom Crops": { + "ID": "spacechase0.CustomCrops", + "Default | UpdateKey": "Nexus:1592" + }, + + "Custom Element Handler": { + "ID": "Platonymous.CustomElementHandler", + "Default | UpdateKey": "Nexus:1068" // added in 1.3.1 + }, + + "Custom Farming Redux": { + "ID": "Platonymous.CustomFarming", + "Default | UpdateKey": "Nexus:991" // added in 0.6.1 + }, + + "Custom Farming Automate Bridge": { + "ID": "Platonymous.CFAutomate", + "~1.0.1 | Status": "AssumeBroken", // no longer compatible with Automate + "~1.0.1 | AlternativeUrl": "https://www.nexusmods.com/stardewvalley/mods/991" + }, + + "Custom Farm Types": { + "ID": "spacechase0.CustomFarmTypes", + "Default | UpdateKey": "Nexus:1140" + }, + + "Custom Furniture": { + "ID": "Platonymous.CustomFurniture", + "Default | UpdateKey": "Nexus:1254" // added in 0.4.1 + }, + + "Customize Exterior": { + "ID": "spacechase0.CustomizeExterior", + "FormerIDs": "CustomizeExterior", // changed in 1.0.3 + "Default | UpdateKey": "Nexus:1099", + "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Customizable Cart Redux": { + "ID": "KoihimeNakamura.CCR", + "MapLocalVersions": { "1.1-20170917": "1.1" }, + "Default | UpdateKey": "Nexus:1402" + }, + + "Customizable Traveling Cart Days": { + "ID": "TravelingCartYyeahdude", + "Default | UpdateKey": "Nexus:567" + }, + + "Custom Linens": { + "ID": "Mevima.CustomLinens", + "MapRemoteVersions": { "1.1": "1.0" }, // manifest not updated + "Default | UpdateKey": "Nexus:1027" + }, + + "Custom NPC": { + "ID": "Platonymous.CustomNPC", + "Default | UpdateKey": "Nexus:1607" + }, + + "Custom Shops Redux": { + "ID": "Omegasis.CustomShopReduxGui", + "Default | UpdateKey": "Nexus:1378" // added in 1.4.1 + }, + + "Custom TV": { + "ID": "Platonymous.CustomTV", + "Default | UpdateKey": "Nexus:1139" // added in 1.0.6 + }, + + "Daily Luck Message": { + "ID": "Schematix.DailyLuckMessage", + "Default | UpdateKey": "Nexus:1327" + }, + + "Daily News": { + "ID": "bashNinja.DailyNews", + "Default | UpdateKey": "Nexus:1141", + "~1.2 | Status": "AssumeBroken" // broke in Stardew Valley 1.3 (or depends on CustomTV which broke) + }, + + "Daily Quest Anywhere": { + "ID": "Omegasis.DailyQuestAnywhere", + "FormerIDs": "DailyQuest", // changed in 1.4 + "Default | UpdateKey": "Nexus:513" // added in 1.4.1 + }, + + "Data Maps": { + "ID": "Pathoschild.DataMaps", + "Default | UpdateKey": "Nexus:1691", + "~1.4 | Status": "AssumeBroken" // replaced by Data Layers + }, + + "Debug Mode": { + "ID": "Pathoschild.DebugMode", + "FormerIDs": "Pathoschild.Stardew.DebugMode", // changed in 1.4 + "Default | UpdateKey": "Nexus:679", + "~1.8 | Status": "AssumeBroken" // broke in SDV 1.3 + }, + + "Did You Water Your Crops?": { + "ID": "Nishtra.DidYouWaterYourCrops", + "Default | UpdateKey": "Nexus:1583" + }, + + "Dynamic Checklist": { + "ID": "gunnargolf.DynamicChecklist", + "Default | UpdateKey": "Nexus:1145" // added in 1.0.1-pathoschild-update + }, + + "Dynamic Horses": { + "ID": "Bpendragon-DynamicHorses", + "MapRemoteVersions": { "1.2": "1.1-release" }, // manifest not updated + "Default | UpdateKey": "Nexus:874" + }, + + "Dynamic Machines": { + "ID": "DynamicMachines", + "MapLocalVersions": { "1.1": "1.1.1" }, + "Default | UpdateKey": "Nexus:374", + "~1.1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Dynamic NPC Sprites": { + "ID": "BashNinja.DynamicNPCSprites", + "Default | UpdateKey": "Nexus:1183" + }, + + "Easier Farming": { + "ID": "cautiouswafffle.EasierFarming", + "Default | UpdateKey": "Nexus:1426" + }, + + "Empty Hands": { + "ID": "QuicksilverFox.EmptyHands", + "Default | UpdateKey": "Nexus:1176" // added in 1.0.1-pathoschild-update + }, + + "Enemy Health Bars": { + "ID": "Speeder.HealthBars", + "FormerIDs": "SPDHealthBar", // changed in 1.7.1-pathoschild-update + "Default | UpdateKey": "Nexus:193" + }, + + "Entoarox Framework": { + "ID": "Entoarox.EntoaroxFramework", + "FormerIDs": "eacdb74b-4080-4452-b16b-93773cda5cf9", // changed in ??? + "~2.0.6 | UpdateKey": "Chucklefish:4228", // only enable update checks up to 2.0.6 by request (has its own update-check feature) + "~2.0.6 | Status": "AssumeBroken" // broke in SMAPI 2.5 (error reflecting into SMAPI internals) + }, + + "Expanded Fridge": { + "ID": "Uwazouri.ExpandedFridge", + "Default | UpdateKey": "Nexus:1191" + }, + + "Experience Bars": { + "ID": "spacechase0.ExperienceBars", + "FormerIDs": "ExperienceBars", // changed in 1.0.2 + "Default | UpdateKey": "Nexus:509" + }, + + "Extended Bus System": { + "ID": "ExtendedBusSystem", + "Default | UpdateKey": "Chucklefish:4373" + }, + + "Extended Fridge": { + "ID": "Crystalmir.ExtendedFridge", + "FormerIDs": "Mystra007ExtendedFridge", // changed in 1.0.1 + "Default | UpdateKey": "Nexus:485" + }, + + "Extended Greenhouse": { + "ID": "ExtendedGreenhouse", + "Default | UpdateKey": "Chucklefish:4303", + "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "Extended Minecart": { + "ID": "Entoarox.ExtendedMinecart", + "~1.7.1 | UpdateKey": "Chucklefish:4359" // only enable update checks up to 1.7.1 by request (has its own update-check feature) + }, + + "Extended Reach": { + "ID": "spacechase0.ExtendedReach", + "Default | UpdateKey": "Nexus:1493" + }, + + "Fall 28 Snow Day": { + "ID": "Omegasis.Fall28SnowDay", + "Default | UpdateKey": "Nexus:486", // added in 1.4.1 + "~1.4.1 | Status": "AssumeBroken" // broke in SMAPI 2.0, and update for SMAPI 2.0 doesn't do anything + }, + + "Farm Automation Unofficial: Item Collector": { + "ID": "Maddy99.FarmAutomation.ItemCollector", + "~0.5 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Farm Expansion": { + "ID": "Advize.FarmExpansion", + "FormerIDs": "3888bdfd-73f6-4776-8bb7-8ad45aea1915 | AdvizeFarmExpansionMod-2-0 | AdvizeFarmExpansionMod-2-0-5", // changed in 2.0, 2.0.5, and 3.0 + "Default | UpdateKey": "Nexus:130" + }, + + "Fast Animations": { + "ID": "Pathoschild.FastAnimations", + "Default | UpdateKey": "Nexus:1089", + "~1.5 | Status": "AssumeBroken" // broke in SDV 1.3 + }, + + "Faster Grass": { + "ID": "IceGladiador.FasterGrass", + "Default | UpdateKey": "Nexus:1772" + }, + + "Faster Paths": { + "ID": "Entoarox.FasterPaths", + "FormerIDs": "615f85f8-5c89-44ee-aecc-c328f172e413", // changed in 1.3 + "~1.3.3 | UpdateKey": "Chucklefish:3641" // only enable update checks up to 1.3.3 by request (has its own update-check feature) + }, + + "Fishing Adjust": { + "ID": "shuaiz.FishingAdjustMod", + "Default | UpdateKey": "Nexus:1350", + "~2.0.1 | Status": "AssumeBroken" // Method not found: 'Void Harmony.HarmonyInstance.Patch(System.Reflection.MethodBase, Harmony.HarmonyMethod, Harmony.HarmonyMethod, Harmony.HarmonyMethod)' + }, + + "Fishing Tuner Redux": { + "ID": "HammurabiFishingTunerRedux", + "Default | UpdateKey": "Chucklefish:4578" + }, + + "Fixed Secret Woods Debris": { + "ID": "f4iTh.WoodsDebrisFix", + "Default | UpdateKey": "Nexus:1941" + }, + + "Fix Scythe Exp": { + "ID": "bcmpinc.FixScytheExp", + "~0.2 | Status": "AssumeBroken" // Exception from HarmonyInstance "bcmpinc.FixScytheExp" [...] Bad label content in ILGenerator. + }, + + "Flower Color Picker": { + "ID": "spacechase0.FlowerColorPicker", + "Default | UpdateKey": "Nexus:1229" + }, + + "Forage at the Farm": { + "ID": "Nishtra.ForageAtTheFarm", + "FormerIDs": "ForageAtTheFarm", // changed in <=1.6 + "Default | UpdateKey": "Nexus:673" + }, + + "Furniture Anywhere": { + "ID": "Entoarox.FurnitureAnywhere", + "~1.1.5 | UpdateKey": "Chucklefish:4324" // only enable update checks up to 1.1.5 by request (has its own update-check feature) + }, + + "Game Reminder": { + "ID": "mmanlapat.GameReminder", + "Default | UpdateKey": "Nexus:1153" + }, + + "Gate Opener": { + "ID": "mralbobo.GateOpener", + "Default | UpdateKey": "GitHub:mralbobo/stardew-gate-opener" + }, + + "GenericShopExtender": { + "ID": "GenericShopExtender", + "Default | UpdateKey": "Nexus:814" // added in 0.1.3 + }, + + "Geode Info Menu": { + "ID": "cat.geodeinfomenu", + "Default | UpdateKey": "Nexus:1448" + }, + + "Get Dressed": { + "ID": "Advize.GetDressed", + "Default | UpdateKey": "Nexus:331" + }, + + "Giant Crop Ring": { + "ID": "cat.giantcropring", + "Default | UpdateKey": "Nexus:1182" + }, + + "Gift Taste Helper": { + "ID": "tstaples.GiftTasteHelper", + "FormerIDs": "8008db57-fa67-4730-978e-34b37ef191d6", // changed in 2.5 + "Default | UpdateKey": "Nexus:229" + }, + + "Grandfather's Gift": { + "ID": "ShadowDragon.GrandfathersGift", + "Default | UpdateKey": "Nexus:985" + }, + + "Happy Animals": { + "ID": "HappyAnimals", + "~1.0.3 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Happy Birthday (Omegasis)": { + "ID": "Omegasis.HappyBirthday", + "Default | UpdateKey": "Nexus:520" // added in 1.4.1 + }, + + "Hardcore Mines": { + "ID": "kibbe.hardcore_mines", + "Default | UpdateKey": "Nexus:1674" + }, + + "Harp of Yoba Redux": { + "ID": "Platonymous.HarpOfYobaRedux", + "Default | UpdateKey": "Nexus:914" // added in 2.0.3 + }, + + "Harvest Moon Witch Princess": { + "ID": "Sasara.WitchPrincess", + "Default | UpdateKey": "Nexus:1157" + }, + + "Harvest With Scythe": { + "ID": "965169fd-e1ed-47d0-9f12-b104535fb4bc", + "Default | UpdateKey": "Nexus:236" + }, + + "Horse Whistle (icepuente)": { + "ID": "icepuente.HorseWhistle", + "Default | UpdateKey": "Nexus:1131", + "~1.1.2-unofficial.1-pathoschild | Status": "AssumeBroken" // causes significant lag, fixed in unofficial.2 + }, + + "Hunger (Yyeadude)": { + "ID": "HungerYyeadude", + "Default | UpdateKey": "Nexus:613" + }, + + "Hunger for Food (Tigerle)": { + "ID": "HungerForFoodByTigerle", + "Default | UpdateKey": "Nexus:810", + "~0.1.2 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Hunger Mod (skn)": { + "ID": "skn.HungerMod", + "MapRemoteVersions": { "1.2.1": "1.0" }, // manifest not updated + "Default | UpdateKey": "Nexus:1127" + }, + + "Idle Pause": { + "ID": "Veleek.IdlePause", + "MapRemoteVersions": { "1.2": "1.1" }, // manifest not updated + "Default | UpdateKey": "Nexus:1092" + }, + + "Improved Quality of Life": { + "ID": "Demiacle.ImprovedQualityOfLife", + "Default | UpdateKey": "Nexus:1025", + "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Instant Geode": { + "ID": "InstantGeode", + "~1.12 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "Instant Grow Trees": { + "ID": "cantorsdust.InstantGrowTrees", + "FormerIDs": "dc50c58b-c7d8-4e60-86cc-e27b5d95ee59 | community.InstantGrowTrees", // changed in 1.2 and 1.3.1 + "Default | UpdateKey": "Nexus:173" + }, + + "Interaction Helper": { + "ID": "HammurabiInteractionHelper", + "Default | UpdateKey": "Chucklefish:4640" // added in 1.0.4-pathoschild-update + }, + + "Item Auto Stacker": { + "ID": "cat.autostacker", + "MapRemoteVersions": { "1.0.1": "1.0" }, // manifest not updated + "Default | UpdateKey": "Nexus:1184" + }, + + "Json Assets": { + "ID": "spacechase0.JsonAssets", + "Default | UpdateKey": "Nexus:1720" + }, + + "Junimo Farm": { + "ID": "Platonymous.JunimoFarm", + "MapRemoteVersions": { "1.1.2": "1.1.1" }, // manifest not updated + "Default | UpdateKey": "Nexus:984" // added in 1.1.3 + }, + + "Less Strict Over-Exertion (AntiExhaustion)": { + "ID": "BALANCEMOD_AntiExhaustion", + "MapLocalVersions": { "0.0": "1.1" }, + "Default | UpdateKey": "Nexus:637", + "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Level Extender": { + "ID": "DevinLematty.LevelExtender", + "FormerIDs": "Devin Lematty.Level Extender", // changed in 1.3 + "Default | UpdateKey": "Nexus:1471" + }, + + "Level Up Notifications": { + "ID": "Level Up Notifications", + "MapRemoteVersions": { "0.0.1a": "0.0.1" }, + "Default | UpdateKey": "Nexus:855" + }, + + "Location and Music Logging": { + "ID": "Brandy Lover.LMlog", + "Default | UpdateKey": "Nexus:1366" + }, + + "Longevity": { + "ID": "RTGOAT.Longevity", + "MapRemoteVersions": { "1.6.8h": "1.6.8" }, + "Default | UpdateKey": "Nexus:649" + }, + + "Lookup Anything": { + "ID": "Pathoschild.LookupAnything", + "FormerIDs": "LookupAnything", // changed in 1.10.1 + "Default | UpdateKey": "Nexus:541", + "~1.18.1 | Status": "AssumeBroken" // broke in SDV 1.3 + }, + + "Love Bubbles": { + "ID": "LoveBubbles", + "Default | UpdateKey": "Nexus:1318" + }, + + "Loved Labels": { + "ID": "Advize.LovedLabels", + "Default | UpdateKey": "Nexus:279" + }, + + "Luck Skill": { + "ID": "spacechase0.LuckSkill", + "FormerIDs": "LuckSkill", // changed in 0.1.4 + "Default | UpdateKey": "Nexus:521" + }, + + "Magic": { + "ID": "spacechase0.Magic", + "MapRemoteVersions": { "0.1.2": "0.1.1" } // not updated in manifest + }, + + "Mail Framework": { + "ID": "DIGUS.MailFrameworkMod", + "Default | UpdateKey": "Nexus:1536" + }, + + "MailOrderPigs": { + "ID": "jwdred.MailOrderPigs", + "Default | UpdateKey": "Nexus:632" + }, + + "Makeshift Multiplayer": { + "ID": "spacechase0.StardewValleyMP", + "FormerIDs": "StardewValleyMP", // changed in 0.3 + "Default | UpdateKey": "Nexus:501" + }, + + "Map Image Exporter": { + "ID": "spacechase0.MapImageExporter", + "FormerIDs": "MapImageExporter", // changed in 1.0.2 + "Default | UpdateKey": "Nexus:1073" + }, + + "Message Box [API]? (ChatMod)": { + "ID": "Kithio:ChatMod", + "Default | UpdateKey": "Chucklefish:4296", + "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Mining at the Farm": { + "ID": "Nishtra.MiningAtTheFarm", + "FormerIDs": "MiningAtTheFarm", // changed in <=1.7 + "Default | UpdateKey": "Nexus:674" + }, + + "Mining With Explosives": { + "ID": "Nishtra.MiningWithExplosives", + "FormerIDs": "MiningWithExplosives", // changed in 1.1 + "Default | UpdateKey": "Nexus:770" + }, + + "Modder Serialization Utility": { + "ID": "SerializerUtils-0-1", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "it's no longer maintained or used." + }, + + "Monster Level Tip": { + "ID": "WhiteMind.MonsterLT", + "Default | UpdateKey": "Nexus:1896" + }, + + "More Animals": { + "ID": "Entoarox.MoreAnimals", + "FormerIDs": "821ce8f6-e629-41ad-9fde-03b54f68b0b6MOREPETS | Entoarox.MorePets", // changed in 1.3 and 2.0 + "~2.0.2 | UpdateKey": "Chucklefish:4288" // only enable update checks up to 2.0.2 by request (has its own update-check feature) + }, + + "More Artifact Spots": { + "ID": "451", + "Default | UpdateKey": "Nexus:451" + }, + + "More Map Layers": { + "ID": "Platonymous.MoreMapLayers", + "Default | UpdateKey": "Nexus:1134" // added in 1.1.1 + }, + + "More Rain": { + "ID": "Omegasis.MoreRain", + "Default | UpdateKey": "Nexus:441", // added in 1.5.1 + "~1.4 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "More Weapons": { + "ID": "Joco80.MoreWeapons", + "Default | UpdateKey": "Nexus:1168" + }, + + "Move Faster": { + "ID": "shuaiz.MoveFasterMod", + "Default | UpdateKey": "Nexus:1351", + "1.0.1 | Status": "AssumeBroken" // doesn't do anything as of SDV 1.2.33 (bad Harmony patch?) + }, + + "Multiple Sprites and Portraits On Rotation (File Loading)": { + "ID": "FileLoading", + "MapLocalVersions": { "1.1": "1.12" }, + "Default | UpdateKey": "Nexus:1094", + "~1.12 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Museum Rearranger": { + "ID": "Omegasis.MuseumRearranger", + "Default | UpdateKey": "Nexus:428" // added in 1.4.1 + }, + + "Mushroom Level Tip": { + "ID": "WhiteMind.MLT", + "Default | UpdateKey": "Nexus:1894" + }, + + "New Machines": { + "ID": "F70D4FAB-0AB2-4B78-9F1B-AF2CA2236A59", + "Default | UpdateKey": "Chucklefish:3683", + "~4.2.1343 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Night Owl": { + "ID": "Omegasis.NightOwl", + "MapLocalVersions": { "2.1": "1.3" }, // 1.3 had wrong version in manifest + "Default | UpdateKey": "Nexus:433" // added in 1.4.1 + }, + + "No Crows": { + "ID": "cat.nocrows", + "Default | UpdateKey": "Nexus:1682" + }, + + "No Kids Ever": { + "ID": "Hangy.NoKidsEver", + "Default | UpdateKey": "Nexus:1464" + }, + + "No Debug Mode": { + "ID": "NoDebugMode", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0." + }, + + "No Fence Decay": { + "ID": "cat.nofencedecay", + "Default | UpdateKey": "Nexus:1180" + }, + + "No More Pets": { + "ID": "Omegasis.NoMorePets", + "FormerIDs": "NoMorePets", // changed in 1.4 + "Default | UpdateKey": "Nexus:506" // added in 1.4.1 + }, + + "No Rumble Horse": { + "ID": "Xangria.NoRumbleHorse", + "Default | UpdateKey": "Nexus:1779" + }, + + "No Soil Decay": { + "ID": "289dee03-5f38-4d8e-8ffc-e440198e8610", + "Default | UpdateKey": "Nexus:237", + "~0.5 | Status": "AssumeBroken" // broke in SDV 1.2 and uses Assembly.GetExecutingAssembly().Location + }, + + "No Soil Decay Redux": { + "ID": "Platonymous.NoSoilDecayRedux", + "Default | UpdateKey": "Nexus:1084" // added in 1.1.9 + }, + + "NPC Map Locations": { + "ID": "Bouhm.NPCMapLocations", + "FormerIDs": "NPCMapLocationsMod", // changed in 2.0 + "Default | UpdateKey": "Nexus:239" + }, + + "Object Time Left": { + "ID": "spacechase0.ObjectTimeLeft", + "Default | UpdateKey": "Nexus:1315" + }, + + "OmniFarm": { + "ID": "PhthaloBlue.OmniFarm", + "FormerIDs": "BlueMod_OmniFarm", // changed in 2.0.2-pathoschild-update + "Default | UpdateKey": "GitHub:lambui/StardewValleyMod_OmniFarm" + }, + + "One Click Shed": { + "ID": "BitwiseJonMods.OneClickShedReloader", + "Default | UpdateKey": "Nexus:2052" + }, + + "Out of Season Bonuses (Seasonal Items)": { + "ID": "midoriarmstrong.seasonalitems", + "Default | UpdateKey": "Nexus:1452" + }, + + "Part of the Community": { + "ID": "SB_PotC", + "Default | UpdateKey": "Nexus:923" + }, + + "PelicanFiber": { + "ID": "jwdred.PelicanFiber", + "Default | UpdateKey": "Nexus:631" + }, + + "PelicanTTS": { + "ID": "Platonymous.PelicanTTS", + "Default | UpdateKey": "Nexus:1079" // added in 1.6.1 + }, + + "Persia the Mermaid - Standalone Custom NPC": { + "ID": "63b9f419-7449-42db-ab2e-440b4d05c073", + "Default | UpdateKey": "Nexus:1419" + }, + + "Persistent Game Options": { + "ID": "Xangria.PersistentGameOptions", + "Default | UpdateKey": "Nexus:1778" + }, + + "Plant on Grass": { + "ID": "Demiacle.PlantOnGrass", + "Default | UpdateKey": "Nexus:1026" + }, + + "PyTK - Platonymous Toolkit": { + "ID": "Platonymous.Toolkit", + "Default | UpdateKey": "Nexus:1726" + }, + + "Point-and-Plant": { + "ID": "jwdred.PointAndPlant", + "Default | UpdateKey": "Nexus:572", + "MapRemoteVersions": { "1.0.3": "1.0.2" } // manifest not updated + }, + + "Pony Weight Loss Program": { + "ID": "BadNetCode.PonyWeightLossProgram", + "Default | UpdateKey": "Nexus:1232" + }, + + "Portraiture": { + "ID": "Platonymous.Portraiture", + "Default | UpdateKey": "Nexus:999" // added in 1.3.1 + }, + + "Prairie King Made Easy": { + "ID": "Mucchan.PrairieKingMadeEasy", + "Default | UpdateKey": "Chucklefish:3594", + "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "Purchasable Recipes": { + "ID": "Paracosm.PurchasableRecipes", + "Default | UpdateKey": "Nexus:1722" + }, + + "Quest Delay": { + "ID": "BadNetCode.QuestDelay", + "Default | UpdateKey": "Nexus:1239" + }, + + "Recatch Legendary Fish": { + "ID": "cantorsdust.RecatchLegendaryFish", + "FormerIDs": "b3af8c31-48f0-43cf-8343-3eb08bcfa1f9 | community.RecatchLegendaryFish", // changed in 1.3 and 1.5.1 + "Default | UpdateKey": "Nexus:172" + }, + + "Regeneration": { + "ID": "HammurabiRegeneration", + "Default | UpdateKey": "Chucklefish:4584" + }, + + "Relationship Bar UI": { + "ID": "RelationshipBar", + "Default | UpdateKey": "Nexus:1009" + }, + + "RelationshipsEnhanced": { + "ID": "relationshipsenhanced", + "Default | UpdateKey": "Chucklefish:4435", + "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Relationship Status": { + "ID": "relationshipstatus", + "MapRemoteVersions": { "1.0.5": "1.0.4" }, // not updated in manifest + "Default | UpdateKey": "Nexus:751", + "~1.0.5 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Rented Tools": { + "ID": "JarvieK.RentedTools", + "Default | UpdateKey": "Nexus:1307" + }, + + "Replanter": { + "ID": "jwdred.Replanter", + "Default | UpdateKey": "Nexus:589" + }, + + "ReRegeneration": { + "ID": "lrsk_sdvm_rerg.0925160827", + "MapLocalVersions": { "1.1.2-release": "1.1.2" }, + "Default | UpdateKey": "Chucklefish:4465" + }, + + "Reseed": { + "ID": "Roc.Reseed", + "Default | UpdateKey": "Nexus:887" + }, + + "Reusable Wallpapers and Floors (Wallpaper Retain)": { + "ID": "dae1b553-2e39-43e7-8400-c7c5c836134b", + "Default | UpdateKey": "Nexus:356", + "~1.5 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Ring of Fire": { + "ID": "Platonymous.RingOfFire", + "Default | UpdateKey": "Nexus:1166" // added in 1.0.1 + }, + + "Rope Bridge": { + "ID": "RopeBridge", + "Default | UpdateKey": "Nexus:824" + }, + + "Rotate Toolbar": { + "ID": "Pathoschild.RotateToolbar", + "Default | UpdateKey": "Nexus:1100", + "~1.2.1 | Status": "AssumeBroken" // broke in SDV 1.3 + }, + + "Rush Orders": { + "ID": "spacechase0.RushOrders", + "FormerIDs": "RushOrders", // changed in 1.1 + "Default | UpdateKey": "Nexus:605" + }, + + "Save Anywhere": { + "ID": "Omegasis.SaveAnywhere", + "Default | UpdateKey": "Nexus:444", // added in 2.6.1 + "MapRemoteVersions": { "2.6.2": "2.6.1" } // not updated in manifest + }, + + "Save Backup": { + "ID": "Omegasis.SaveBackup", + "Default | UpdateKey": "Nexus:435", // added in 1.3.1 + "~1.2 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Scroll to Blank": { + "ID": "caraxian.scroll.to.blank", + "Default | UpdateKey": "Chucklefish:4405" + }, + + "Scythe Harvesting": { + "ID": "mmanlapat.ScytheHarvesting", + "FormerIDs": "ScytheHarvesting", // changed in 1.6 + "Default | UpdateKey": "Nexus:1106" + }, + + "SDV Twitch": { + "ID": "MTD.SDVTwitch", + "Default | UpdateKey": "Nexus:1760" + }, + + "Seasonal Immersion": { + "ID": "Entoarox.SeasonalImmersion", + "FormerIDs": "EntoaroxSeasonalHouse | EntoaroxSeasonalBuildings | EntoaroxSeasonalImmersion", // changed in 1.1, 1.6 or earlier, and 1.7 + "~1.11 | UpdateKey": "Chucklefish:4262" // only enable update checks up to 1.11 by request (has its own update-check feature) + }, + + "Seed Bag": { + "ID": "Platonymous.SeedBag", + "Default | UpdateKey": "Nexus:1133" // added in 1.1.2 + }, + + "Seed Catalogue": { + "ID": "spacechase0.SeedCatalogue", + "Default | UpdateKey": "Nexus:1640" + }, + + "Self Service": { + "ID": "JarvieK.SelfService", + "MapRemoteVersions": { "0.2.1": "0.2" }, // manifest not updated + "Default | UpdateKey": "Nexus:1304" + }, + + "Send Items": { + "ID": "Denifia.SendItems", + "Default | UpdateKey": "Nexus:1087" // added in 1.0.3 (2017-10-04) + }, + + "Shed Notifications (BuildingsNotifications)": { + "ID": "TheCroak.BuildingsNotifications", + "Default | UpdateKey": "Nexus:620", + "~0.4.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Shenandoah Project": { + "ID": "Nishtra.ShenandoahProject", + "FormerIDs": "Shenandoah Project", // changed in 1.2 + "MapRemoteVersions": { "1.1.1": "1.1" }, // not updated in manifest + "Default | UpdateKey": "Nexus:756" + }, + + "Ship Anywhere": { + "ID": "spacechase0.ShipAnywhere", + "Default | UpdateKey": "Nexus:1379" + }, + + "Shipment Tracker": { + "ID": "7e474181-e1a0-40f9-9c11-d08a3dcefaf3", + "Default | UpdateKey": "Nexus:321" + }, + + "Shop Expander": { + "ID": "Entoarox.ShopExpander", + "FormerIDs": "EntoaroxShopExpander", // changed in 1.5 and 1.5.2; disambiguate from Faster Paths + "MapRemoteVersions": { "1.6.0b": "1.6.0" }, + "~1.6 | UpdateKey": "Chucklefish:4381" // only enable update checks up to 1.6 by request (has its own update-check feature) + }, + + "Showcase Mod": { + "ID": "Igorious.Showcase", + "MapLocalVersions": { "0.9-500": "0.9" }, + "Default | UpdateKey": "Chucklefish:4487", + "~0.9 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Shroom Spotter": { + "ID": "TehPers.ShroomSpotter", + "Default | UpdateKey": "Nexus:908" + }, + + "Simple Crop Label": { + "ID": "SimpleCropLabel", + "Default | UpdateKey": "Nexus:314" + }, + + "Simple Sound Manager": { + "ID": "Omegasis.SimpleSoundManager", + "Default | UpdateKey": "Nexus:1410" // added in 1.0.1 + }, + + "Simple Sprinklers": { + "ID": "tZed.SimpleSprinkler", + "Default | UpdateKey": "Nexus:76" + }, + + "Siv's Marriage Mod": { + "ID": "6266959802", // official version + "FormerIDs": "Siv.MarriageMod | medoli900.Siv's Marriage Mod", // 1.2.3-unofficial versions + "MapLocalVersions": { "0.0": "1.4" }, + "Default | UpdateKey": "Nexus:366" + }, + + "Skill Prestige": { + "ID": "alphablackwolf.skillPrestige", + "FormerIDs": "6b843e60-c8fc-4a25-a67b-4a38ac8dcf9b", // changed circa 1.2.3 + "Default | UpdateKey": "Nexus:569" + }, + + "Skill Prestige: Cooking Adapter": { + "ID": "Alphablackwolf.CookingSkillPrestigeAdapter", + "FormerIDs": "20d6b8a3-b6e7-460b-a6e4-07c2b0cb6c63", // changed circa 1.1 + "MapRemoteVersions": { "1.2.3": "1.1" }, // manifest not updated + "Default | UpdateKey": "Nexus:569" + }, + + "Skip Intro": { + "ID": "Pathoschild.SkipIntro", + "FormerIDs": "SkipIntro", // changed in 1.4 + "Default | UpdateKey": "Nexus:533", + "~1.7.2 | Status": "AssumeBroken" // broke in SDV 1.3 + }, + + "Skull Cavern Elevator": { + "ID": "SkullCavernElevator", + "Default | UpdateKey": "Nexus:963" + }, + + "Skull Cave Saver": { + "ID": "cantorsdust.SkullCaveSaver", + "FormerIDs": "8ac06349-26f7-4394-806c-95d48fd35774 | community.SkullCaveSaver", // changed in 1.1 and 1.2.2 + "Default | UpdateKey": "Nexus:175", + "1.3-beta | Status": "AssumeBroken" // doesn't work in multiplayer, no longer maintained + }, + + "Sleepy Eye": { + "ID": "spacechase0.SleepyEye", + "Default | UpdateKey": "Nexus:1152" + }, + + "Slower Fence Decay": { + "ID": "Speeder.SlowerFenceDecay", + "FormerIDs": "SPDSlowFenceDecay", // changed in 0.5.2-pathoschild-update + "Default | UpdateKey": "Nexus:252", + "~0.5.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Smart Mod": { + "ID": "KuroBear.SmartMod", + "~2.2 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Solar Eclipse Event": { + "ID": "KoihimeNakamura.SolarEclipseEvent", + "Default | UpdateKey": "Nexus:897", + "MapLocalVersions": { "1.3.1-20180131": "1.3.1" } + }, + + "SpaceCore": { + "ID": "spacechase0.SpaceCore", + "Default | UpdateKey": "Nexus:1348" + }, + + "Speedster": { + "ID": "Platonymous.Speedster", + "Default | UpdateKey": "Nexus:1102" // added in 1.3.1 + }, + + "Split Screen": { + "ID": "Ilyaki.SplitScreen", + "~3.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.16 due to reflection into SMAPI internals + }, + + "Sprinkler Range": { + "ID": "cat.sprinklerrange", + "Default | UpdateKey": "Nexus:1179" + }, + + "Sprinkles": { + "ID": "Platonymous.Sprinkles", + "Default | UpdateKey": "Chucklefish:4592" + }, + + "Sprint and Dash": { + "ID": "SPDSprintAndDash", + "Default | UpdateKey": "Nexus:235", + "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "Sprint and Dash Redux": { + "ID": "littleraskol.SprintAndDashRedux", + "FormerIDs": "lrsk_sdvm_sndr.0921161059", // changed in 1.3 + "Default | UpdateKey": "Chucklefish:4201" + }, + + "StackSplitX": { + "ID": "tstaples.StackSplitX", + "Default | UpdateKey": "Nexus:798" + }, + + "Stardew Config Menu": { + "ID": "Juice805.StardewConfigMenu", + "Default | UpdateKey": "Nexus:1312" + }, + + "Stardew Content Compatibility Layer (SCCL)": { + "ID": "SCCL", + "Default | UpdateKey": "Nexus:889", + "~0.1 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "Stardew Editor Game Integration": { + "ID": "spacechase0.StardewEditor.GameIntegration", + "Default | UpdateKey": "Nexus:1298" + }, + + "Stardew Notification": { + "ID": "stardewnotification", + "Default | UpdateKey": "GitHub:monopandora/StardewNotification" + }, + + "Stardew Symphony": { + "ID": "Omegasis.StardewSymphony", + "Default | UpdateKey": "Nexus:425" // added in 1.4.1 + }, + + "StarDustCore": { + "ID": "StarDustCore", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "it was only used by earlier versions of Save Anywhere, and is no longer used or maintained." + }, + + "Starting Money": { + "ID": "mmanlapat.StartingMoney", + "FormerIDs": "StartingMoney", // changed in 1.1 + "Default | UpdateKey": "Nexus:1138" + }, + + "StashItemsToChest": { + "ID": "BlueMod_StashItemsToChest", + "Default | UpdateKey": "GitHub:lambui/StardewValleyMod_StashItemsToChest", + "~1.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Stephan's Lots of Crops": { + "ID": "stephansstardewcrops", + "MapRemoteVersions": { "1.41": "1.1" }, // manifest not updated + "Default | UpdateKey": "Chucklefish:4314" + }, + + "Stumps to Hardwood Stumps": { + "ID": "StumpsToHardwoodStumps", + "Default | UpdateKey": "Nexus:691" + }, + + "Summit Reborn": { + "ID": "KoihimeNakamura.summitreborn", + "FormerIDs": "emissaryofinfinity.summitreborn", // changed in 1.0.2 + "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.3 (runtime errors) + }, + + "Super Greenhouse Warp Modifier": { + "ID": "SuperGreenhouse", + "Default | UpdateKey": "Chucklefish:4334", + "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0 + }, + + "Swim Almost Anywhere / Swim Suit": { + "ID": "Platonymous.SwimSuit", + "Default | UpdateKey": "Nexus:1215" // added in 0.5.1 + }, + + "Tapper Ready": { + "ID": "skunkkk.TapperReady", + "Default | UpdateKey": "Nexus:1219" + }, + + "Teh's Fishing Overhaul": { + "ID": "TehPers.FishingOverhaul", + "Default | UpdateKey": "Nexus:866" + }, + + "Teleporter": { + "ID": "Teleporter", + "Default | UpdateKey": "Chucklefish:4374", + "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "The Long Night": { + "ID": "Pathoschild.TheLongNight", + "Default | UpdateKey": "Nexus:1369", + "~1.1.1 | Status": "AssumeBroken" // broke in SDV 1.3 + }, + + "Three-heart Dance Partner": { + "ID": "ThreeHeartDancePartner", + "Default | UpdateKey": "Nexus:500" + }, + + "TimeFreeze": { + "ID": "Omegasis.TimeFreeze", + "FormerIDs": "4108e859-333c-4fec-a1a7-d2e18c1019fe", // changed in 1.2 + "Default | UpdateKey": "Nexus:973" // added in 1.2.1 + }, + + "Time Reminder": { + "ID": "KoihimeNakamura.TimeReminder", + "MapLocalVersions": { "1.0-20170314": "1.0.2" }, + "Default | UpdateKey": "Nexus:1000" + }, + + "TimeSpeed": { + "ID": "cantorsdust.TimeSpeed", + "FormerIDs": "community.TimeSpeed", // changed in 2.3.3 + "Default | UpdateKey": "Nexus:169" + }, + + "To Do List": { + "ID": "eleanor.todolist", + "Default | UpdateKey": "Nexus:1630" + }, + + "Tool Charging": { + "ID": "mralbobo.ToolCharging", + "Default | UpdateKey": "GitHub:mralbobo/stardew-tool-charging" + }, + + "TractorMod": { + "ID": "Pathoschild.TractorMod", + "FormerIDs": "BlueMod_TractorMod | PhthaloBlue.TractorMod | community.TractorMod", // changed in 3.2, 4.0 beta, and 4.0 + "Default | UpdateKey": "Nexus:1401", + "~4.5-beta | Status": "AssumeBroken" // broke in SDV 1.3 + }, + + "TrainerMod": { + "ID": "SMAPI.TrainerMod", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "replaced by ConsoleCommands, which is added by the SMAPI installer." + }, + + "Tree Transplant": { + "ID": "TreeTransplant", + "Default | UpdateKey": "Nexus:1342" + }, + + "UI Info Suite": { + "ID": "Cdaragorn.UiInfoSuite", + "Default | UpdateKey": "Nexus:1150" + }, + + "UiModSuite": { + "ID": "Demiacle.UiModSuite", + "MapLocalVersions": { "0.5": "1.0" }, // not updated in manifest + "Default | UpdateKey": "Nexus:1023", + "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "Variable Grass": { + "ID": "dantheman999.VariableGrass", + "Default | UpdateKey": "GitHub:dantheman999301/StardewMods" + }, + + "Vertical Toolbar": { + "ID": "SB_VerticalToolMenu", + "Default | UpdateKey": "Nexus:943" + }, + + "WarpAnimals": { + "ID": "Symen.WarpAnimals", + "Default | UpdateKey": "Nexus:1400" + }, + + "What Farm Cave / WhatAMush": { + "ID": "WhatAMush", + "Default | UpdateKey": "Nexus:1097" + }, + + "WHats Up": { + "ID": "wHatsUp", + "Default | UpdateKey": "Nexus:1082" + }, + + "Winter Grass": { + "ID": "cat.wintergrass", + "Default | UpdateKey": "Nexus:1601" + }, + + "Xnb Loader": { + "ID": "Entoarox.XnbLoader", + "~1.1.10 | UpdateKey": "Chucklefish:4506" // only enable update checks up to 1.1.10 by request (has its own update-check feature) + }, + + "zDailyIncrease": { + "ID": "zdailyincrease", + "MapRemoteVersions": { "1.3.5": "1.3.4" }, // not updated in manifest + "Default | UpdateKey": "Chucklefish:4247" + }, + + "Zoom Out Extreme": { + "ID": "RockinMods.ZoomMod", + "FormerIDs": "ZoomMod", // changed circa 1.2.1 + "Default | UpdateKey": "Nexus:1326", + "~0.1 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "Zoryn's Better RNG": { + "ID": "Zoryn.BetterRNG", + "FormerIDs": "76b6d1e1-f7ba-4d72-8c32-5a1e6d2716f6", // changed in 1.6 + "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods", + "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "Zoryn's Calendar Anywhere": { + "ID": "Zoryn.CalendarAnywhere", + "FormerIDs": "a41c01cd-0437-43eb-944f-78cb5a53002a", // changed in 1.6 + "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods", + "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "Zoryn's Durable Fences": { + "ID": "Zoryn.DurableFences", + "FormerIDs": "56d3439c-7b9b-497e-9496-0c4890e8a00e", // changed in 1.6 + "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods" + }, + + "Zoryn's Health Bars": { + "ID": "Zoryn.HealthBars", + "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods", + "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "Zoryn's Fishing Mod": { + "ID": "Zoryn.FishingMod", + "FormerIDs": "fa277b1f-265e-47c3-a84f-cd320cc74949", // changed in 1.6 + "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods" + }, + + "Zoryn's Junimo Deposit Anywhere": { + "ID": "Zoryn.JunimoDepositAnywhere", + "FormerIDs": "f93a4fe8-cade-4146-9335-b5f82fbbf7bc", // changed in 1.6 + "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods", + "~1.7 | Status": "AssumeBroken" // broke in SDV 1.2 + }, + + "Zoryn's Movement Mod": { + "ID": "Zoryn.MovementModifier", + "FormerIDs": "8a632929-8335-484f-87dd-c29d2ba3215d", // changed in 1.6 + "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods" + }, + + "Zoryn's Regen Mod": { + "ID": "Zoryn.RegenMod", + "FormerIDs": "dfac4383-1b6b-4f33-ae4e-37fc23e5252e", // changed in 1.6 + "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods", + "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2 + } + } +} |