summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/release-notes.md6
-rw-r--r--src/SMAPI.Web/Controllers/LogParserController.cs35
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParseException.cs15
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs222
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs24
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs24
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/ModInfo.cs24
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs47
-rw-r--r--src/SMAPI.Web/ViewModels/LogParserModel.cs9
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml219
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/log-parser.css325
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/log-parser.js321
-rw-r--r--src/SMAPI.sln2
-rw-r--r--src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs2
14 files changed, 771 insertions, 504 deletions
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 08f945e4..03b6dd77 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -20,8 +20,14 @@
* Fixed unhelpful error when a translation file has duplicate keys due to case-insensitivity.
* Fixed some JSON field names being case-sensitive.
+* For the [log parser][]:
+ * Significantly reduced download size when viewing files with repeated errors.
+ * Improved parse error handling.
+ * Fixed 'log started' field showing incorrect date.
+
* For SMAPI developers:
* Overhauled mod DB format to be more concise, reduce the memory footprint, and support versioning/defaulting more fields.
+ * Reimplemented log parser with serverside parsing and vue.js on the frontend.
## 2.4
* For players:
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs
index 04a11a82..62547deb 100644
--- a/src/SMAPI.Web/Controllers/LogParserController.cs
+++ b/src/SMAPI.Web/Controllers/LogParserController.cs
@@ -8,6 +8,8 @@ using Microsoft.Extensions.Options;
using StardewModdingAPI.Web.Framework;
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
using StardewModdingAPI.Web.Framework.ConfigModels;
+using StardewModdingAPI.Web.Framework.LogParsing;
+using StardewModdingAPI.Web.Framework.LogParsing.Models;
using StardewModdingAPI.Web.ViewModels;
namespace StardewModdingAPI.Web.Controllers
@@ -52,25 +54,23 @@ namespace StardewModdingAPI.Web.Controllers
[HttpGet]
[Route("log")]
[Route("log/{id}")]
- public ViewResult Index(string id = null)
+ public async Task<ViewResult> Index(string id = null)
{
- return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id));
+ // fresh page
+ if (string.IsNullOrWhiteSpace(id))
+ return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, null));
+
+ // 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));
}
/***
** JSON
***/
- /// <summary>Fetch raw text from Pastebin.</summary>
- /// <param name="id">The Pastebin paste ID.</param>
- [HttpGet, Produces("application/json")]
- [Route("log/fetch/{id}")]
- public async Task<PasteInfo> GetAsync(string id)
- {
- PasteInfo response = await this.Pastebin.GetAsync(id);
- response.Content = this.DecompressString(response.Content);
- return response;
- }
-
/// <summary>Save raw log data.</summary>
/// <param name="content">The log content to save.</param>
[HttpPost, Produces("application/json"), AllowLargePosts]
@@ -85,6 +85,15 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Private methods
*********/
+ /// <summary>Fetch raw text from Pastebin.</summary>
+ /// <param name="id">The Pastebin paste ID.</param>
+ private async Task<PasteInfo> GetAsync(string id)
+ {
+ PasteInfo response = await this.Pastebin.GetAsync(id);
+ response.Content = this.DecompressString(response.Content);
+ return response;
+ }
+
/// <summary>Compress a string.</summary>
/// <param name="text">The text to compress.</param>
/// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks>
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs
new file mode 100644
index 00000000..5d4c8c08
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace StardewModdingAPI.Web.Framework.LogParsing
+{
+ /// <summary>An error while parsing the log file which doesn't require a stack trace to troubleshoot.</summary>
+ internal class LogParseException : Exception
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="message">The user-friendly error message.</param>
+ public LogParseException(string message) : base(message) { }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
new file mode 100644
index 00000000..1c3b5671
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using StardewModdingAPI.Web.Framework.LogParsing.Models;
+
+namespace StardewModdingAPI.Web.Framework.LogParsing
+{
+ /// <summary>Parses SMAPI log files.</summary>
+ public class LogParser
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>A regex pattern matching the start of a SMAPI message.</summary>
+ private readonly Regex MessageHeaderPattern = new Regex(@"^\[(?<time>\d\d:\d\d:\d\d) (?<level>[a-z]+) +(?<modName>[^\]]+)\] ", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ /// <summary>A regex pattern matching SMAPI's initial platform info message.</summary>
+ private readonly Regex InfoLinePattern = new Regex(@"^SMAPI (?<apiVersion>.+) with Stardew Valley (?<gameVersion>.+) on (?<os>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ /// <summary>A regex pattern matching SMAPI's mod folder path line.</summary>
+ private readonly Regex ModPathPattern = new Regex(@"^Mods go here: (?<path>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ /// <summary>A regex pattern matching SMAPI's log timestamp line.</summary>
+ private readonly Regex LogStartedAtPattern = new Regex(@"^Log started at (?<timestamp>.+) UTC", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ /// <summary>A regex pattern matching the start of SMAPI's mod list.</summary>
+ private readonly Regex ModListStartPattern = new Regex(@"^Loaded \d+ mods:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ /// <summary>A regex pattern matching an entry in SMAPI's mod list.</summary>
+ private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| (?<description>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Parse SMAPI log text.</summary>
+ /// <param name="logText">The SMAPI log text.</param>
+ public ParsedLog Parse(string logText)
+ {
+ try
+ {
+ // skip if empty
+ if (string.IsNullOrWhiteSpace(logText))
+ {
+ return new ParsedLog
+ {
+ IsValid = false,
+ Error = "The log is empty."
+ };
+ }
+
+ // init log
+ ParsedLog log = new ParsedLog
+ {
+ IsValid = true,
+ Messages = this.CollapseRepeats(this.GetMessages(logText)).ToArray(),
+ };
+
+ // parse log messages
+ LogModInfo smapiMod = new LogModInfo { Name = "SMAPI", Author = "Pathoschild", Description = "" };
+ IDictionary<string, LogModInfo> mods = new Dictionary<string, LogModInfo>();
+ bool inModList = false;
+ foreach (LogMessage message in log.Messages)
+ {
+ // collect stats
+ if (message.Level == LogLevel.Error)
+ {
+ if (message.Mod == "SMAPI")
+ smapiMod.Errors++;
+ else if (mods.ContainsKey(message.Mod))
+ mods[message.Mod].Errors++;
+ }
+
+ // collect SMAPI metadata
+ if (message.Mod == "SMAPI")
+ {
+ // update flags
+ if (inModList && !this.ModListEntryPattern.IsMatch(message.Text))
+ inModList = false;
+
+ // mod list
+ if (!inModList && message.Level == LogLevel.Info && this.ModListStartPattern.IsMatch(message.Text))
+ inModList = true;
+ else if (inModList)
+ {
+ Match match = this.ModListEntryPattern.Match(message.Text);
+ string name = match.Groups["name"].Value;
+ string version = match.Groups["version"].Value;
+ string author = match.Groups["author"].Value;
+ string description = match.Groups["description"].Value;
+ mods[name] = new LogModInfo { Name = name, Author = author, Version = version, Description = description };
+ }
+
+ // platform info line
+ else if (message.Level == LogLevel.Info && this.InfoLinePattern.IsMatch(message.Text))
+ {
+ Match match = this.InfoLinePattern.Match(message.Text);
+ log.ApiVersion = match.Groups["apiVersion"].Value;
+ log.GameVersion = match.Groups["gameVersion"].Value;
+ log.OperatingSystem = match.Groups["os"].Value;
+ smapiMod.Version = log.ApiVersion;
+ }
+
+ // mod path line
+ else if (message.Level == LogLevel.Debug && this.ModPathPattern.IsMatch(message.Text))
+ {
+ Match match = this.ModPathPattern.Match(message.Text);
+ log.ModPath = match.Groups["path"].Value;
+ }
+
+ // log UTC timestamp line
+ else if (message.Level == LogLevel.Trace && this.LogStartedAtPattern.IsMatch(message.Text))
+ {
+ Match match = this.LogStartedAtPattern.Match(message.Text);
+ log.Timestamp = DateTime.Parse(match.Groups["timestamp"].Value + "Z");
+ }
+ }
+ }
+
+ // finalise log
+ log.Mods = new[] { smapiMod }.Concat(mods.Values.OrderBy(p => p.Name)).ToArray();
+ return log;
+ }
+ catch (LogParseException ex)
+ {
+ return new ParsedLog
+ {
+ IsValid = false,
+ Error = ex.Message,
+ RawTextIfError = logText
+ };
+ }
+ catch (Exception ex)
+ {
+ return new ParsedLog
+ {
+ IsValid = false,
+ Error = $"Parsing the log file failed. Technical details:\n{ex}",
+ RawTextIfError = logText
+ };
+ }
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Collapse consecutive repeats into the previous message.</summary>
+ /// <param name="messages">The messages to filter.</param>
+ private IEnumerable<LogMessage> CollapseRepeats(IEnumerable<LogMessage> messages)
+ {
+ LogMessage next = null;
+ foreach (LogMessage message in messages)
+ {
+ // new message
+ if (next == null)
+ {
+ next = message;
+ continue;
+ }
+
+ // repeat
+ if (next.Level == message.Level && next.Mod == message.Mod && next.Text == message.Text)
+ {
+ next.Repeated++;
+ continue;
+ }
+
+ // non-repeat message
+ yield return next;
+ next = message;
+ }
+ yield return next;
+ }
+
+ /// <summary>Split a SMAPI log into individual log messages.</summary>
+ /// <param name="logText">The SMAPI log text.</param>
+ /// <exception cref="LogParseException">The log text can't be parsed successfully.</exception>
+ private IEnumerable<LogMessage> GetMessages(string logText)
+ {
+ LogMessage message = new LogMessage();
+ using (StringReader reader = new StringReader(logText))
+ {
+ while (true)
+ {
+ // read data
+ string line = reader.ReadLine();
+ if (line == null)
+ break;
+ Match header = this.MessageHeaderPattern.Match(line);
+
+ // validate
+ if (message.Text == null && !header.Success)
+ throw new LogParseException("Found a log message with no SMAPI metadata. Is this a SMAPI log file?");
+
+ // start or continue message
+ if (header.Success)
+ {
+ if (message.Text != null)
+ yield return message;
+
+ message = new LogMessage
+ {
+ Time = header.Groups["time"].Value,
+ Level = Enum.Parse<LogLevel>(header.Groups["level"].Value, ignoreCase: true),
+ Mod = header.Groups["modName"].Value,
+ Text = line.Substring(header.Length)
+ };
+ }
+ else
+ message.Text += "\n" + line;
+ }
+
+ // end last message
+ if (message.Text != null)
+ yield return message;
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs
new file mode 100644
index 00000000..40d21bf8
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogLevel.cs
@@ -0,0 +1,24 @@
+namespace StardewModdingAPI.Web.Framework.LogParsing.Models
+{
+ /// <summary>The log severity levels.</summary>
+ public enum LogLevel
+ {
+ /// <summary>Tracing info intended for developers.</summary>
+ Trace,
+
+ /// <summary>Troubleshooting info that may be relevant to the player.</summary>
+ Debug,
+
+ /// <summary>Info relevant to the player. This should be used judiciously.</summary>
+ Info,
+
+ /// <summary>An issue the player should be aware of. This should be used rarely.</summary>
+ Warn,
+
+ /// <summary>A message indicating something went wrong.</summary>
+ 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
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs
new file mode 100644
index 00000000..baeac83c
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs
@@ -0,0 +1,24 @@
+namespace StardewModdingAPI.Web.Framework.LogParsing.Models
+{
+ /// <summary>A parsed log message.</summary>
+ public class LogMessage
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The local time when the log was posted.</summary>
+ public string Time { get; set; }
+
+ /// <summary>The log level.</summary>
+ public LogLevel Level { get; set; }
+
+ /// <summary>The mod name.</summary>
+ public string Mod { get; set; }
+
+ /// <summary>The log text.</summary>
+ public string Text { get; set; }
+
+ /// <summary>The number of times this message was repeated consecutively.</summary>
+ public int Repeated { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ModInfo.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ModInfo.cs
new file mode 100644
index 00000000..2005e61f
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/ModInfo.cs
@@ -0,0 +1,24 @@
+namespace StardewModdingAPI.Web.Framework.LogParsing.Models
+{
+ /// <summary>Metadata about a mod or content pack in the log.</summary>
+ public class LogModInfo
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The mod author.</summary>
+ public string Author { get; set; }
+
+ /// <summary>The mod version.</summary>
+ public string Version { get; set; }
+
+ /// <summary>The mod description.</summary>
+ public string Description { get; set; }
+
+ /// <summary>The number of errors logged by this mod.</summary>
+ public int Errors { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
new file mode 100644
index 00000000..31ef2fe1
--- /dev/null
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
@@ -0,0 +1,47 @@
+using System;
+
+namespace StardewModdingAPI.Web.Framework.LogParsing.Models
+{
+ /// <summary>Parsed metadata for a log.</summary>
+ public class ParsedLog
+ {
+ /*********
+ ** Accessors
+ *********/
+ /****
+ ** Metadata
+ ****/
+ /// <summary>Whether the log file was successfully parsed.</summary>
+ public bool IsValid { get; set; }
+
+ /// <summary>An error message indicating why the log file is invalid.</summary>
+ public string Error { get; set; }
+
+ /// <summary>The raw text if <see cref="IsValid"/> is false.</summary>
+ public string RawTextIfError { get; set; }
+
+ /****
+ ** Log data
+ ****/
+ /// <summary>The SMAPI version.</summary>
+ public string ApiVersion { get; set; }
+
+ /// <summary>The game version.</summary>
+ public string GameVersion { get; set; }
+
+ /// <summary>The player's operating system.</summary>
+ public string OperatingSystem { get; set; }
+
+ /// <summary>The mod folder path.</summary>
+ public string ModPath { get; set; }
+
+ /// <summary>The ISO 8601 timestamp when the log was started.</summary>
+ public DateTimeOffset Timestamp { get; set; }
+
+ /// <summary>Metadata about installed mods and content packs.</summary>
+ public LogModInfo[] Mods { get; set; } = new LogModInfo[0];
+
+ /// <summary>The log messages.</summary>
+ public LogMessage[] Messages { get; set; }
+ }
+}
diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs
index b5b3b14c..8c026536 100644
--- a/src/SMAPI.Web/ViewModels/LogParserModel.cs
+++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs
@@ -1,3 +1,5 @@
+using StardewModdingAPI.Web.Framework.LogParsing.Models;
+
namespace StardewModdingAPI.Web.ViewModels
{
/// <summary>The view model for the log parser page.</summary>
@@ -12,6 +14,9 @@ namespace StardewModdingAPI.Web.ViewModels
/// <summary>The paste ID.</summary>
public string PasteID { get; set; }
+ /// <summary>The parsed log info.</summary>
+ public ParsedLog ParsedLog { get; set; }
+
/*********
** Public methods
@@ -22,10 +27,12 @@ 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>
- public LogParserModel(string sectionUrl, string pasteID)
+ /// <param name="parsedLog">The parsed log info.</param>
+ public LogParserModel(string sectionUrl, string pasteID, ParsedLog parsedLog)
{
this.SectionUrl = sectionUrl;
this.PasteID = pasteID;
+ this.ParsedLog = parsedLog;
}
}
}
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index 1659de8f..8d1abbb1 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -1,15 +1,29 @@
@{
ViewData["Title"] = "SMAPI log parser";
}
+@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=20180101" />
+ <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=20180101"></script>
- <style type="text/css" id="modflags"></style>
<script>
$(function() {
- smapi.logParser('@Model.SectionUrl', '@Model.PasteID');
+ smapi.logParser({
+ logStarted: new Date(@Json.Serialize(Model.ParsedLog?.Timestamp)),
+ showPopup: @Json.Serialize(Model.ParsedLog == null),
+ showMods: @Json.Serialize(Model.ParsedLog?.Mods?.ToDictionary(p => p.Name, p => true), new JsonSerializerSettings { Formatting = Formatting.None }),
+ showLevels: {
+ trace: false,
+ debug: false,
+ info: true,
+ alert: true,
+ warn: true,
+ error: true
+ }
+ }, '@Model.SectionUrl');
});
</script>
}
@@ -20,99 +34,122 @@
<p id="blurb">This page lets you upload, view, and share a SMAPI log to help troubleshoot mod issues.</p>
<input type="button" id="upload-button" value="Share a new log" />
-@if (Model.PasteID != null)
+@if (Model.ParsedLog?.IsValid == true)
{
<h2>Parsed log</h2>
-}
-<div id="output" class="trace debug">
- @if (Model.PasteID != null)
- {
- <div id="log-data" style="display: none;">
- <div class="always">
- <table id="gameinfo">
- <caption>Game info:</caption>
- <tr>
- <td>SMAPI Version</td>
- <td id="api-version"></td>
- </tr>
- <tr>
- <td>Game Version</td>
- <td id="game-version"></td>
- </tr>
- <tr>
- <td>Platform</td>
- <td id="platform"></td>
- </tr>
- <tr>
- <td>Mods path</td>
- <td id="mods-path"></td>
- </tr>
- <tr>
- <td>Log started</td>
- <td id="log-started"></td>
- </tr>
- </table>
- <br/>
- <table id="modslist">
- <caption>Installed Mods: <span id="modlink-r" class="notice btn">Remove all mod filters</span><span class="notice txt"><i>Click any mod to filter</i></span> <span id="modlink-a" class="notice btn txt">Select all</span></caption>
- </table>
- <div id="filters">
- Filter messages: <span>TRACE</span> | <span>DEBUG</span> | <span class="active">INFO</span> | <span class="active">ALERT</span> | <span class="active">WARN</span> | <span class="active">ERROR</span>
- </div>
- </div>
- <table id="log"></table>
- </div>
- }
- <div id="error" class="color-red"></div>
-</div>
-<script class="template" id="template-css" type="text/html">
- #output.modfilter:not(.mod-{0}) .mod-{0} { display:none; } #output.modfilter.mod-{0} #modslist tr { background:#ffeeee; } #output.modfilter.mod-{0} #modslist tr#modlink-{0} { background:#eeffee; }
-</script>
-<script class="template" id="template-modentry" type="text/html">
- <tr id="modlink-{0}">
- <td>{1}</td>
- <td>{2}</td>
- <td>{3}</td>
- <td class={4}>{5}</td>
- </tr>
-</script>
-<script class="template" id="template-logentry" type="text/html">
- <tr class="{0} mod mod-{1}">
- <td>{2}</td>
- <td>{3}</td>
- <td data-title="{4}">{4}</td>
- <td>{5}</td>
- </tr>
-</script>
-<script class="template" id="template-lognotice" type="text/html">
- <tr class="{0} mod-repeat mod mod-{1}">
- <td colspan="3"></td>
- <td><i>repeats [{2}] times.</i></td>
- </tr>
-</script>
-<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>
- <li>Share the URL of the new page.</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 id="output">
+ <table id="metadata">
+ <caption>Game info:</caption>
+ <tr>
+ <td>SMAPI version:</td>
+ <td>@Model.ParsedLog.ApiVersion</td>
+ </tr>
+ <tr>
+ <td>Game version:</td>
+ <td>@Model.ParsedLog.GameVersion</td>
+ </tr>
+ <tr>
+ <td>Platform:</td>
+ <td>@Model.ParsedLog.OperatingSystem</td>
+ </tr>
+ <tr>
+ <td>Mods path:</td>
+ <td>@Model.ParsedLog.ModPath</td>
+ </tr>
+ <tr>
+ <td>Log started:</td>
+ <td>@Model.ParsedLog.Timestamp.UtcDateTime.ToString("yyyy-MM-dd HH:mm") UTC ({{localTimeStarted}} your time)</td>
+ </tr>
+ </table>
+ <br />
+ <table id="mods">
+ <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-if="stats.modsHidden > 0">show all</span>
+ <span class="notice btn txt" v-on:click="hideAllMods" v-if="stats.modsShown > 0 && stats.modsHidden > 0">hide all</span>
+ </caption>
+ @foreach (var mod in Model.ParsedLog.Mods)
+ {
+ <tr v-on:click="toggleMod('@mod.Name')" class="mod-entry" v-bind:class="{ hidden: !showMods['@mod.Name'] }">
+ <td><input type="checkbox" v-bind:checked="showMods['@mod.Name']" v-if="anyModsHidden" /></td>
+ <td>@mod.Version</td>
+ <td>@mod.Author</td>
+ @if (mod.Errors == 0)
+ {
+ <td class="color-green">no errors</td>
+ }
+ else if (mod.Errors == 1)
+ {
+ <td class="color-red">@mod.Errors error</td>
+ }
+ else
+ {
+ <td class="color-red">@mod.Errors errors</td>
+ }
+ </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();
+
+ <tr class="@levelStr mod" v-if="showMods['@message.Mod'] && showLevels['@levelStr']">
+ <td>@message.Time</td>
+ <td>@message.Level.ToString().ToUpper()</td>
+ <td data-title="@message.Mod">@message.Mod</td>
+ <td>@message.Text</td>
+ </tr>
+ if (message.Repeated > 0)
+ {
+ <tr class="@levelStr mod mod-repeat" v-if="showMods['@message.Mod'] && showLevels['@levelStr']">
+ <td colspan="3"></td>
+ <td><i>repeats [@message.Repeated] times.</i></td>
+ </tr>
+ }
+ }
+ </table>
</div>
-</div>
-<div id="popup-raw" class="popup">
- <h1>Raw log file</h1>
- <div class="frame">
- <textarea id="dataraw"></textarea>
- <div class="buttons">
- <input type="button" id="closeraw" value="Close" />
+}
+else if (Model.ParsedLog?.IsValid == false)
+{
+ <h2>Parsed log</h2>
+ <div id="error" class="color-red">
+ <p><strong>We couldn't parse that file, but you can still share the link.</strong></p>
+ <p>Error details: @Model.ParsedLog.Error</p>
+ </div>
+
+ <h3>Raw log</h3>
+ <pre>@Model.ParsedLog.RawTextIfError</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>
+ <li>Share the URL of the new page.</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>
-<div id="uploader"></div>
diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
index 9f07f9e8..a3be0c85 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
@@ -1,21 +1,23 @@
-.mod-repeat {
- font-size: 8pt;
+/*********
+** 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;
}
-.template {
- display: none;
+caption {
+ text-align: left;
+ padding-top: 2px;
}
-.popup, #uploader {
- position: fixed;
- top: 0px;
- left: 0px;
- right: 0px;
- bottom: 0;
- background-color: rgba(0, 0, 0, .33);
- z-index: 2;
- display: none;
- padding: 5px;
+#output {
+ padding: 10px;
+ overflow: auto;
+ font-family: monospace;
}
#upload-button {
@@ -27,101 +29,81 @@
background: #eef;
}
-
-#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;
+/*********
+** Log metadata & filters
+*********/
+#metadata, #mods, #filters {
font-weight: bold;
- text-align: center;
- border-radius: 5px;
+ border-bottom: 1px dashed #888888;
+ padding-bottom: 10px;
+ margin-bottom: 5px;
}
-.popup h1 {
- position: absolute;
- top: 10%;
- left: 50%;
- margin-left: -150px;
- text-align: center;
- width: 300px;
- border: 1px solid #008;
+table#metadata,
+table#mods {
+ border: 1px solid #000000;
+ background: #ffffff;
border-radius: 5px;
- background: #fff;
- font-family: sans-serif;
- font-size: 40px;
- margin-top: -25px;
- z-index: 10;
- border-bottom: 0;
+ border-spacing: 1px;
+ overflow: hidden;
+ cursor: default;
+ box-shadow: 1px 1px 1px 1px #dddddd;
}
-.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;
+#mods {
+ min-width: 400px;
}
-input[type="button"] {
- font-size: 20px;
- border-radius: 5px;
- outline: none;
- box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 0, .2);
- cursor: pointer;
+#mods .color-red {
+ color: red;
}
-#input[type="button"]:hover {
- background-color: #fee;
+#mods .color-green {
+ color: green;
}
-#cancel, #closeraw {
- border: 1px solid #880000;
- background-color: #fcc;
+#mods tr {
+ cursor: pointer;
}
-#submit {
- border: 1px solid #008800;
- background-color: #cfc;
+#metadata tr,
+#mods tr {
+ background: #eee
}
-#submit:hover {
- background-color: #efe;
+#mods span.notice {
+ font-weight: normal;
+ font-size: 11px;
+ position: relative;
+ top: -1px;
+ display: none;
}
-#input, #dataraw {
- width: 100%;
- height: 30em;
- max-height: 70%;
- margin: auto;
- box-sizing: border-box;
+#mods span.notice.btn {
+ cursor: pointer;
+ border: 1px solid #000;
border-radius: 5px;
- border: 1px solid #000088;
- outline: none;
- box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2);
+ position: relative;
+ top: -1px;
+ padding: 0 2px;
+ background: #eee;
}
-.color-red {
- color: red;
+#mods span.notice.txt {
+ display: inline-block;
}
-.color-green {
- color: green;
+#mods .mod-entry.hidden {
+ opacity: 0.5;
+}
+
+#metadata td:first-child {
+ padding-right: 5px;
+}
+
+#metadata tr:nth-child(even),
+#mods tr:nth-child(even) {
+ background: #fff
}
#filters {
@@ -155,59 +137,35 @@ input[type="button"] {
background: #efe;
}
-#output {
- padding: 10px;
- overflow: auto;
- font-family: monospace;
-}
-
-#output > * {
- display: block;
-}
-
-#output.trace .trace,
-#output.debug .debug,
-#output.info .info,
-#output.alert .alert,
-#output.warn .warn,
-#output.error .error {
- display: none;
+/*********
+** Log
+*********/
+#log .mod-repeat {
+ font-size: 0.85em;
}
-#output .trace {
+#log .trace {
color: #999;
}
-#output .debug {
+#log .debug {
color: #595959;
}
-#output .info {
- color: #000
+#log .info {
+ color: #000;
}
-#output .alert {
+#log .alert {
color: #b0b;
}
-#output .warn {
- color: #f80
-}
-
-#output .error {
- color: #f00
+#log .warn {
+ color: #f80;
}
-#output .always {
- font-weight: bold;
- border-bottom: 1px dashed #888888;
- padding-bottom: 10px;
- margin-bottom: 5px;
-}
-
-caption {
- text-align: left;
- padding-top: 2px;
+#log .error {
+ color: #f00;
}
#log {
@@ -224,6 +182,7 @@ caption {
border-bottom: 1px dotted #ccc;
border-top: 2px solid #fff;
vertical-align: top;
+ white-space: pre-wrap;
}
#log td:not(:last-child) {
@@ -259,61 +218,99 @@ caption {
width: 100%;
}
-table#gameinfo,
-table#modslist {
- border: 1px solid #000000;
- background: #ffffff;
- border-radius: 5px;
- border-spacing: 1px;
- overflow: hidden;
- cursor: default;
- box-shadow: 1px 1px 1px 1px #dddddd;
+#error {
+ color: #f00;
}
-#modslist {
- min-width: 400px;
-}
-#gameinfo td:first-child {
- padding-right: 5px;
+/*********
+** Upload popup
+*********/
+#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;
}
-#gameinfo tr,
-#modslist tr {
- background: #eee
+#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;
}
-#gameinfo tr:nth-child(even),
-#modslist tr:nth-child(even) {
- background: #fff
+#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;
}
-#modslist tr {
- cursor: pointer;
+#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;
}
-span.notice {
- font-weight: normal;
- font-size: 11px;
- position: relative;
- top: -1px;
- display: none;
+#upload-area #cancel {
+ border: 1px solid #880000;
+ background-color: #fcc;
}
-span.notice.btn {
- cursor: pointer;
- border: 1px solid #000;
- border-radius: 5px;
- position: relative;
- top: -1px;
- padding: 0 2px;
- background: #eee;
+#upload-area #submit {
+ border: 1px solid #008800;
+ background-color: #cfc;
}
-#output:not(.modfilter) span.notice.txt {
- display: inline-block;
+#upload-area #submit:hover {
+ background-color: #efe;
}
-#output.modfilter span.notice.btn {
- display: inline-block;
+#upload-area #input {
+ width: 100%;
+ height: 30em;
+ max-height: 70%;
+ margin: auto;
+ box-sizing: border-box;
+ border-radius: 5px;
+ border: 1px solid #000088;
+ outline: none;
+ box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2);
}
diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
index 914863f6..87a70391 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
@@ -1,48 +1,98 @@
/* globals $ */
var smapi = smapi || {};
-smapi.logParser = function(sectionUrl, pasteID) {
- /*********
- ** Initialisation
- *********/
- var stage,
- flags = $("#modflags"),
- output = $("#output"),
- error = $("#error"),
- filters = 0,
- memory = "",
- versionInfo,
- modInfo,
- modMap,
- modErrors,
- logInfo,
- templateModentry = $("#template-modentry").text(),
- templateCss = $("#template-css").text(),
- templateLogentry = $("#template-logentry").text(),
- templateLognotice = $("#template-lognotice").text(),
- regexInfo = /\[[\d\:]+ INFO SMAPI] SMAPI (.*?) with Stardew Valley (.*?) on (.*?)\n/g,
- regexMods = /\[[^\]]+\] Loaded \d+ mods:(?:\n\[[^\]]+\] .+)+/g,
- regexLog = /\[([\d\:]+) (TRACE|DEBUG|INFO|WARN|ALERT|ERROR) ? ([^\]]+)\] ?((?:\n|.)*?)(?=(?:\[\d\d:|$))/g,
- regexMod = /\[(?:.*?)\] *(.*?) (\d+\.?(?:.*?))(?: by (.*?))? \|(?:.*?)$/gm,
- regexDate = /\[\d{2}:\d{2}:\d{2} TRACE SMAPI\] Log started at (.*?) UTC/g,
- regexPath = /\[\d{2}:\d{2}:\d{2} DEBUG SMAPI\] Mods go here: (.*?)(?:\n|$)/g;
+var app;
+smapi.logParser = function (data, sectionUrl) {
+ // internal filter counts
+ var stats = data.stats = {
+ modsShown: 0,
+ modsHidden: 0
+ };
+ function updateModFilters() {
+ // counts
+ stats.modsShown = 0;
+ stats.modsHidden = 0;
+ for (var key in data.showMods) {
+ if (data.showMods.hasOwnProperty(key)) {
+ if (data.showMods[key])
+ stats.modsShown++;
+ else
+ stats.modsHidden++;
+ }
+ }
+ }
- $("#filters span").on("click", function(evt) {
- var t = $(evt.currentTarget);
- t.toggleClass("active");
- output.toggleClass(t.text().toLowerCase());
+ // set local time started
+ if(data)
+ data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2);
+
+ // init app
+ app = new Vue({
+ el: '#output',
+ data: data,
+ computed: {
+ anyModsHidden: function () {
+ return stats.modsHidden > 0;
+ },
+ anyModsShown: function () {
+ return stats.modsShown > 0;
+ }
+ },
+ methods: {
+ toggleLevel: function(id) {
+ this.showLevels[id] = !this.showLevels[id];
+ },
+
+ toggleMod: function (id) {
+ var curShown = this.showMods[id];
+
+ // first filter: only show this by default
+ if (stats.modsHidden === 0) {
+ this.hideAllMods();
+ this.showMods[id] = true;
+ }
+
+ // unchecked last filter: reset
+ else if (stats.modsShown === 1 && curShown)
+ this.showAllMods();
+
+ // else toggle
+ else
+ this.showMods[id] = !this.showMods[id];
+
+ updateModFilters();
+ },
+ showAllMods: function () {
+ for (var key in this.showMods) {
+ if (this.showMods.hasOwnProperty(key)) {
+ this.showMods[key] = true;
+ }
+ }
+ updateModFilters();
+ },
+ hideAllMods: function () {
+ for (var key in this.showMods) {
+ if (this.showMods.hasOwnProperty(key)) {
+ this.showMods[key] = false;
+ }
+ }
+ updateModFilters();
+ }
+ }
});
+
+ /**********
+ ** Upload form
+ *********/
+ var error = $("#error");
+
$("#upload-button").on("click", function() {
- memory = $("#input").val() || "";
$("#input").val("");
$("#popup-upload").fadeIn();
});
var closeUploadPopUp = function() {
- $("#popup-upload").fadeOut(400, function() {
- $("#input").val(memory);
- memory = "";
- });
+ $("#popup-upload").fadeOut(400);
};
$("#popup-upload").on({
@@ -77,7 +127,7 @@ smapi.logParser = function(sectionUrl, pasteID) {
$("#popup-upload").fadeOut();
var paste = $("#input").val();
if (paste) {
- memory = "";
+ //memory = "";
$("#uploader").attr("data-text", "Saving...");
$("#uploader").fadeIn();
$
@@ -105,210 +155,15 @@ smapi.logParser = function(sectionUrl, pasteID) {
});
$(document).on("keydown", function(e) {
- if (e.which == 27) {
- if ($("#popup-upload").css("display") !== "none" && $("#popup-upload").css("opacity") == 1) {
+ if (e.which === 27) {
+ if ($("#popup-upload").css("display") !== "none" && $("#popup-upload").css("opacity") === 1) {
closeUploadPopUp();
}
-
- $("#popup-raw").fadeOut(400);
}
});
$("#cancel").on("click", closeUploadPopUp);
- $("#closeraw").on("click", function() {
- $("#popup-raw").fadeOut(400);
- });
-
- $("#popup-raw").on("click", function(e) {
- if (e.target.id === "popup-raw") {
- $("#popup-raw").fadeOut(400);
- }
- });
-
- if (pasteID) {
- getData(pasteID);
- }
- else
+ if (data.showPopup)
$("#popup-upload").fadeIn();
-
- /*********
- ** Helpers
- *********/
- function modClicked(evt) {
- var id = $(evt.currentTarget).attr("id").split("-")[1],
- cls = "mod-" + id;
- if (output.hasClass(cls))
- filters--;
- else
- filters++;
- output.toggleClass(cls);
- if (filters === 0) {
- output.removeClass("modfilter");
- } else {
- output.addClass("modfilter");
- }
- }
-
- function removeFilter() {
- for (var c = 0; c < modInfo.length; c++) {
- output.removeClass("mod-" + c);
- }
- filters = 0;
- output.removeClass("modfilter");
- }
-
- function selectAll() {
- for (var c = 0; c < modInfo.length; c++) {
- output.addClass("mod-" + c);
- }
- filters = modInfo.length;
- output.addClass("modfilter");
- }
-
- function parseData() {
- stage = "parseData.pre";
- var data = $("#input").val();
- if (!data) {
- stage = "parseData.checkNullData";
- throw new Error("Field `data` is null");
-
- }
- var dataInfo = regexInfo.exec(data) || regexInfo.exec(data) || regexInfo.exec(data),
- dataMods = regexMods.exec(data) || regexMods.exec(data) || regexMods.exec(data) || [""],
- dataDate = regexDate.exec(data) || regexDate.exec(data) || regexDate.exec(data),
- dataPath = regexPath.exec(data) || regexPath.exec(data) || regexPath.exec(data),
- match;
- stage = "parseData.doNullCheck";
- if (!dataInfo)
- throw new Error("Field `dataInfo` is null");
- if (!dataMods)
- throw new Error("Field `dataMods` is null");
- if (!dataPath)
- throw new Error("Field `dataPath` is null");
- dataMods = dataMods[0];
- stage = "parseData.setupDefaults";
- modMap = {
- "SMAPI": 0
- };
- modErrors = {
- "SMAPI": 0,
- "Console.Out": 0
- };
- logInfo = [];
- modInfo = [
- ["SMAPI", dataInfo[1], "Zoryn, CLxS & Pathoschild"]
- ];
- stage = "parseData.parseInfo";
- var date = dataDate ? new Date(dataDate[1] + "Z") : null;
- versionInfo = {
- apiVersion: dataInfo[1],
- gameVersion: dataInfo[2],
- platform: dataInfo[3],
- logDate: date ? date.getFullYear() + "-" + ("0" + date.getMonth().toString()).substr(-2) + "-" + ("0" + date.getDay().toString()).substr(-2) + " at " + date.getHours() + ":" + date.getMinutes() + ":" + date.getSeconds() + " " + date.toLocaleTimeString("en-us", { timeZoneName: "short" }).split(" ")[2] : "No timestamp found",
- modsPath: dataPath[1]
- };
- stage = "parseData.parseMods";
- while ((match = regexMod.exec(dataMods))) {
- modErrors[match[1]] = 0;
- modMap[match[1]] = modInfo.length;
- modInfo.push([match[1], match[2], match[3] ? ("by " + match[3]) : "Unknown author"]);
- }
- stage = "parseData.parseLog";
- while ((match = regexLog.exec(data))) {
- if (match[2] === "ERROR")
- modErrors[match[3]]++;
- logInfo.push([match[1], match[2], match[3], match[4]]);
- }
- stage = "parseData.post";
- modMap["Console.Out"] = modInfo.length;
- modInfo.push(["Console.Out", "", ""]);
- }
-
- function renderData() {
- stage = "renderData.pre";
-
- output.find("#api-version").text(versionInfo.apiVersion);
- output.find("#game-version").text(versionInfo.gameVersion);
- output.find("#platform").text(versionInfo.platform);
- output.find("#log-started").text(versionInfo.logDate);
- output.find("#mods-path").text(versionInfo.modsPath);
-
- var modslist = $("#modslist"), log = $("#log"), modCache = [], y = 0;
- for (; y < modInfo.length; y++) {
- var errors = modErrors[modInfo[y][0]],
- err, cls = "color-red";
- if (errors === 0) {
- err = "No Errors";
- cls = "color-green";
- } else if (errors === 1)
- err = "1 Error";
- else
- err = errors + " Errors";
- modCache.push(prepare(templateModentry, [y, modInfo[y][0], modInfo[y][1], modInfo[y][2], cls, err]));
- }
- modslist.append(modCache.join(""));
- for (var z = 0; z < modInfo.length; z++)
- $("#modlink-" + z).on("click", modClicked);
- var flagCache = [];
- for (var c = 0; c < modInfo.length; c++)
- flagCache.push(prepare(templateCss, [c]));
- flags.html(flagCache.join(""));
- var logCache = [], dupeCount = 0, dupeMemory = "|||";
- for (var x = 0; x < logInfo.length; x++) {
- var dm = logInfo[x][1] + "|" + logInfo[x][2] + "|" + logInfo[x][3];
- if (dupeMemory !== dm) {
- if (dupeCount > 0)
- logCache.push(prepare(templateLognotice, [logInfo[x - 1][1].toLowerCase(), modMap[logInfo[x - 1][2]], dupeCount]));
- dupeCount = 0;
- dupeMemory = dm;
- logCache.push(prepare(templateLogentry, [logInfo[x][1].toLowerCase(), modMap[logInfo[x][2]], logInfo[x][0], logInfo[x][1], logInfo[x][2], logInfo[x][3].split(" ").join("&nbsp ").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\n/g, "<br />")]));
- }
- else
- dupeCount++;
- }
- log.append(logCache.join(""));
- $("#modlink-r").on("click", removeFilter);
- $("#modlink-a").on("click", selectAll);
-
- $("#log-data").show();
- }
-
- function prepare(str, arr) {
- var regex = /\{(\d)\}/g,
- match;
- while ((match = regex.exec(str)))
- str = str.replace(match[0], arr[match[1]]);
- return str;
- }
- function loadData() {
- try {
- stage = "loadData.Pre";
- parseData();
- renderData();
- $("#viewraw").on("click", function() {
- $("#dataraw").val($("#input").val());
- $("#popup-raw").fadeIn();
- });
- stage = "loadData.Post";
- }
- catch (err) {
- error.html('<h1>Parsing failed!</h1>Parsing of the log failed, details follow.<br />&nbsp;<p>Stage: ' + stage + "</p>" + err + '<hr /><pre id="rawlog"></pre>');
- $("#rawlog").text($("#input").val());
- }
- }
- function getData(pasteID) {
- $("#uploader").attr("data-text", "Loading...");
- $("#uploader").fadeIn();
- $.get(sectionUrl + "/fetch/" + pasteID, function(data) {
- if (data.success) {
- $("#input").val(data.content);
- loadData();
- } else {
- error.html('<h1>Fetching the log failed!</h1><p>' + data.error + '</p><pre id="rawlog"></pre>');
- $("#rawlog").text($("#input").val());
- }
- $("#uploader").fadeOut();
- });
- }
};
diff --git a/src/SMAPI.sln b/src/SMAPI.sln
index b42e39ce..6577b42e 100644
--- a/src/SMAPI.sln
+++ b/src/SMAPI.sln
@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
-VisualStudioVersion = 15.0.27004.2002
+VisualStudioVersion = 15.0.27130.2036
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Mods.ConsoleCommands", "SMAPI.Mods.ConsoleCommands\StardewModdingAPI.Mods.ConsoleCommands.csproj", "{28480467-1A48-46A7-99F8-236D95225359}"
EndProject
diff --git a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs
index 6a7e0519..cfa23d08 100644
--- a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs
+++ b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs
@@ -23,7 +23,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>The instruction is compatible, but uses the <c>dynamic</c> keyword which won't work on Linux/Mac.</summary>
DetectedDynamic,
- /// <summary>The instruction is compatible, but references <see cref="SpecialisedEvents.InvokeUnvalidatedUpdateTick"/> which may impact stability.</summary>
+ /// <summary>The instruction is compatible, but references <see cref="SpecialisedEvents.UnvalidatedUpdateTick"/> which may impact stability.</summary>
DetectedUnvalidatedUpdateTick
}
}