diff options
-rw-r--r-- | src/SMAPI.Web/Controllers/LogParserController.cs | 8 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs | 25 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/LogParsing/LogParseException.cs | 2 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/LogParsing/LogParser.cs | 54 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs | 38 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs | 71 | ||||
-rw-r--r-- | src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs | 20 | ||||
-rw-r--r-- | src/SMAPI.Web/ViewModels/LogParserModel.cs | 51 | ||||
-rw-r--r-- | src/SMAPI.Web/Views/LogParser/Index.cshtml | 195 | ||||
-rw-r--r-- | src/SMAPI.Web/wwwroot/Content/css/log-parser.css | 115 | ||||
-rw-r--r-- | src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 750 | ||||
-rw-r--r-- | src/SMAPI.sln.DotSettings | 2 |
12 files changed, 1142 insertions, 189 deletions
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index 524cfbcc..93f2613e 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Linq; using System.Text; @@ -47,7 +45,7 @@ namespace StardewModdingAPI.Web.Controllers [HttpGet] [Route("log")] [Route("log/{id}")] - public async Task<ActionResult> Index(string id = null, LogViewFormat format = LogViewFormat.Default, bool renew = false) + public async Task<ActionResult> Index(string? id = null, LogViewFormat format = LogViewFormat.Default, bool renew = false) { // fresh page if (string.IsNullOrWhiteSpace(id)) @@ -89,7 +87,7 @@ namespace StardewModdingAPI.Web.Controllers public async Task<ActionResult> PostAsync() { // get raw log text - string input = this.Request.Form["input"].FirstOrDefault(); + string? input = this.Request.Form["input"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(input)) return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty.")); @@ -111,7 +109,7 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="expiry">When the uploaded file will no longer be available.</param> /// <param name="uploadWarning">A non-blocking warning while uploading the log.</param> /// <param name="uploadError">An error which occurred while uploading the log.</param> - private LogParserModel GetModel(string pasteID, DateTime? expiry = null, string uploadWarning = null, string uploadError = null) + private LogParserModel GetModel(string? pasteID, DateTime? expiry = null, string? uploadWarning = null, string? uploadError = null) { Platform? platform = this.DetectClientPlatform(); diff --git a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs index 1b692e63..a1384b8f 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs @@ -1,6 +1,5 @@ -#nullable disable - using System; +using System.Diagnostics.CodeAnalysis; using System.Text; using StardewModdingAPI.Web.Framework.LogParsing.Models; @@ -13,7 +12,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing ** Fields *********/ /// <summary>The local time when the next log was posted.</summary> - public string Time { get; set; } + public string? Time { get; set; } /// <summary>The log level for the next log message.</summary> public LogLevel Level { get; set; } @@ -22,7 +21,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing public int ScreenId { get; set; } /// <summary>The mod name for the next log message.</summary> - public string Mod { get; set; } + public string? Mod { get; set; } /// <summary>The text for the next log message.</summary> private readonly StringBuilder Text = new(); @@ -32,6 +31,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing ** Accessors *********/ /// <summary>Whether the next log message has been started.</summary> + [MemberNotNullWhen(true, nameof(LogMessageBuilder.Time), nameof(LogMessageBuilder.Mod))] public bool Started { get; private set; } @@ -72,19 +72,18 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } /// <summary>Get a log message for the accumulated values.</summary> - public LogMessage Build() + public LogMessage? Build() { if (!this.Started) return null; - return new LogMessage - { - Time = this.Time, - Level = this.Level, - ScreenId = this.ScreenId, - Mod = this.Mod, - Text = this.Text.ToString() - }; + return new LogMessage( + time: this.Time, + level: this.Level, + screenId: this.ScreenId, + mod: this.Mod, + text: this.Text.ToString() + ); } /// <summary>Reset to start a new log message.</summary> diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs index 4ee58433..3f815e3e 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; namespace StardewModdingAPI.Web.Framework.LogParsing diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 4e61ac95..55272b23 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Generic; using System.IO; @@ -55,7 +53,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing *********/ /// <summary>Parse SMAPI log text.</summary> /// <param name="logText">The SMAPI log text.</param> - public ParsedLog Parse(string logText) + public ParsedLog Parse(string? logText) { try { @@ -79,8 +77,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing }; // parse log messages - LogModInfo smapiMod = new() { Name = "SMAPI", Author = "Pathoschild", Description = "", Loaded = true }; - LogModInfo gameMod = new() { Name = "game", Author = "", Description = "", Loaded = true }; + LogModInfo smapiMod = new(name: "SMAPI", author: "Pathoschild", version: "", description: "", loaded: true); + LogModInfo gameMod = new(name: "game", author: "", version: "", description: "", loaded: true); IDictionary<string, List<LogModInfo>> mods = new Dictionary<string, List<LogModInfo>>(); bool inModList = false; bool inContentPackList = false; @@ -103,7 +101,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing default: if (mods.TryGetValue(message.Mod, out var entries)) { - foreach (var entry in entries) + foreach (LogModInfo entry in entries) entry.Errors++; } break; @@ -133,9 +131,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing string author = match.Groups["author"].Value; string description = match.Groups["description"].Value; - if (!mods.TryGetValue(name, out List<LogModInfo> entries)) + if (!mods.TryGetValue(name, out List<LogModInfo>? entries)) mods[name] = entries = new List<LogModInfo>(); - entries.Add(new LogModInfo { Name = name, Author = author, Version = version, Description = description, Loaded = true }); + entries.Add(new LogModInfo(name: name, author: author, version: version, description: description, loaded: true)); message.Section = LogSection.ModsList; } @@ -156,9 +154,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing string description = match.Groups["description"].Value; string forMod = match.Groups["for"].Value; - if (!mods.TryGetValue(name, out List<LogModInfo> entries)) + if (!mods.TryGetValue(name, out List<LogModInfo>? entries)) mods[name] = entries = new List<LogModInfo>(); - entries.Add(new LogModInfo { Name = name, Author = author, Version = version, Description = description, ContentPackFor = forMod, Loaded = true }); + entries.Add(new LogModInfo(name: name, author: author, version: version, description: description, contentPackFor: forMod, loaded: true)); message.Section = LogSection.ContentPackList; } @@ -179,23 +177,19 @@ namespace StardewModdingAPI.Web.Framework.LogParsing if (mods.TryGetValue(name, out var entries)) { - foreach (var entry in entries) - { - entry.UpdateLink = link; - entry.UpdateVersion = version; - } + foreach (LogModInfo entry in entries) + entry.SetUpdate(version, link); } message.Section = LogSection.ModUpdateList; } - else if (message.Level == LogLevel.Alert && this.SmapiUpdatePattern.IsMatch(message.Text)) { Match match = this.SmapiUpdatePattern.Match(message.Text); string version = match.Groups["version"].Value; string link = match.Groups["link"].Value; - smapiMod.UpdateVersion = version; - smapiMod.UpdateLink = link; + + smapiMod.SetUpdate(version, link); } // platform info line @@ -205,7 +199,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing log.ApiVersion = match.Groups["apiVersion"].Value; log.GameVersion = match.Groups["gameVersion"].Value; log.OperatingSystem = match.Groups["os"].Value; - smapiMod.Version = log.ApiVersion; + smapiMod.OverrideVersion(log.ApiVersion); } // mod path line @@ -215,7 +209,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing log.ModPath = match.Groups["path"].Value; int lastDelimiterPos = log.ModPath.LastIndexOfAny(new[] { '/', '\\' }); log.GamePath = lastDelimiterPos >= 0 - ? log.ModPath.Substring(0, lastDelimiterPos) + ? log.ModPath[..lastDelimiterPos] : log.ModPath; } @@ -229,7 +223,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } // finalize log - gameMod.Version = log.GameVersion; + if (log.GameVersion != null) + gameMod.OverrideVersion(log.GameVersion); log.Mods = new[] { gameMod, smapiMod }.Concat(mods.Values.SelectMany(p => p).OrderBy(p => p.Name)).ToArray(); return log; } @@ -261,7 +256,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing /// <param name="messages">The messages to filter.</param> private IEnumerable<LogMessage> CollapseRepeats(IEnumerable<LogMessage> messages) { - LogMessage next = null; + LogMessage? next = null; + foreach (LogMessage message in messages) { // new message @@ -282,7 +278,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing yield return next; next = message; } - yield return next; + + if (next != null) + yield return next; } /// <summary>Split a SMAPI log into individual log messages.</summary> @@ -295,7 +293,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing while (true) { // read line - string line = reader.ReadLine(); + string? line = reader.ReadLine(); if (line == null) break; @@ -308,17 +306,17 @@ namespace StardewModdingAPI.Web.Framework.LogParsing { if (builder.Started) { - yield return builder.Build(); + yield return builder.Build()!; builder.Clear(); } - var screenGroup = header.Groups["screen"]; + Group screenGroup = header.Groups["screen"]; builder.Start( time: header.Groups["time"].Value, level: Enum.Parse<LogLevel>(header.Groups["level"].Value, ignoreCase: true), screenId: screenGroup.Success ? int.Parse(screenGroup.Value) : 0, // main player is always screen ID 0 mod: header.Groups["modName"].Value, - text: line.Substring(header.Length) + text: line[header.Length..] ); } else @@ -332,7 +330,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing // end last message if (builder.Started) - yield return builder.Build(); + yield return builder.Build()!; } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs index 57d28755..7a5f32e0 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs @@ -1,4 +1,4 @@ -#nullable disable +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Web.Framework.LogParsing.Models { @@ -9,19 +9,19 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models ** Accessors *********/ /// <summary>The local time when the log was posted.</summary> - public string Time { get; set; } + public string Time { get; } /// <summary>The log level.</summary> - public LogLevel Level { get; set; } + public LogLevel Level { get; } /// <summary>The screen ID in split-screen mode.</summary> - public int ScreenId { get; set; } + public int ScreenId { get; } /// <summary>The mod name.</summary> - public string Mod { get; set; } + public string Mod { get; } /// <summary>The log text.</summary> - public string Text { get; set; } + public string Text { get; } /// <summary>The number of times this message was repeated consecutively.</summary> public int Repeated { get; set; } @@ -30,6 +30,32 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models public LogSection? Section { get; set; } /// <summary>Whether this message is the first one of its section.</summary> + [MemberNotNullWhen(true, nameof(LogMessage.Section))] public bool IsStartOfSection { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance/</summary> + /// <param name="time">The local time when the log was posted.</param> + /// <param name="level">The log level.</param> + /// <param name="screenId">The screen ID in split-screen mode.</param> + /// <param name="mod">The mod name.</param> + /// <param name="text">The log text.</param> + /// <param name="repeated">The number of times this message was repeated consecutively.</param> + /// <param name="section">The section that this log message belongs to.</param> + /// <param name="isStartOfSection">Whether this message is the first one of its section.</param> + public LogMessage(string time, LogLevel level, int screenId, string mod, string text, int repeated = 0, LogSection? section = null, bool isStartOfSection = false) + { + this.Time = time; + this.Level = level; + this.ScreenId = screenId; + this.Mod = mod; + this.Text = text; + this.Repeated = repeated; + this.Section = section; + this.IsStartOfSection = isStartOfSection; + } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs index 349312df..a6b9165c 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs @@ -1,4 +1,4 @@ -#nullable disable +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Web.Framework.LogParsing.Models { @@ -9,36 +9,81 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models ** Accessors *********/ /// <summary>The mod name.</summary> - public string Name { get; set; } + public string Name { get; } /// <summary>The mod author.</summary> - public string Author { get; set; } - - /// <summary>The update version.</summary> - public string UpdateVersion { get; set; } - - /// <summary>The update link.</summary> - public string UpdateLink { get; set; } + public string Author { get; } /// <summary>The mod version.</summary> - public string Version { get; set; } + public string Version { get; private set; } /// <summary>The mod description.</summary> - public string Description { get; set; } + public string Description { get; } + + /// <summary>The update version.</summary> + public string? UpdateVersion { get; private set; } + + /// <summary>The update link.</summary> + public string? UpdateLink { get; private set; } /// <summary>The name of the mod for which this is a content pack (if applicable).</summary> - public string ContentPackFor { get; set; } + public string? ContentPackFor { get; } /// <summary>The number of errors logged by this mod.</summary> public int Errors { get; set; } /// <summary>Whether the mod was loaded into the game.</summary> - public bool Loaded { get; set; } + public bool Loaded { get; } /// <summary>Whether the mod has an update available.</summary> + [MemberNotNullWhen(true, nameof(LogModInfo.UpdateVersion), nameof(LogModInfo.UpdateLink))] public bool HasUpdate => this.UpdateVersion != null && this.Version != this.UpdateVersion; /// <summary>Whether the mod is a content pack for another mod.</summary> + [MemberNotNullWhen(true, nameof(LogModInfo.ContentPackFor))] public bool IsContentPack => !string.IsNullOrWhiteSpace(this.ContentPackFor); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The mod name.</param> + /// <param name="author">The mod author.</param> + /// <param name="version">The mod version.</param> + /// <param name="description">The mod description.</param> + /// <param name="updateVersion">The update version.</param> + /// <param name="updateLink">The update link.</param> + /// <param name="contentPackFor">The name of the mod for which this is a content pack (if applicable).</param> + /// <param name="errors">The number of errors logged by this mod.</param> + /// <param name="loaded">Whether the mod was loaded into the game.</param> + public LogModInfo(string name, string author, string version, string description, string? updateVersion = null, string? updateLink = null, string? contentPackFor = null, int errors = 0, bool loaded = true) + { + this.Name = name; + this.Author = author; + this.Version = version; + this.Description = description; + this.UpdateVersion = updateVersion; + this.UpdateLink = updateLink; + this.ContentPackFor = contentPackFor; + this.Errors = errors; + this.Loaded = loaded; + } + + /// <summary>Add an update alert for this mod.</summary> + /// <param name="updateVersion">The update version.</param> + /// <param name="updateLink">The update link.</param> + public void SetUpdate(string updateVersion, string updateLink) + { + this.UpdateVersion = updateVersion; + this.UpdateLink = updateLink; + } + + /// <summary>Override the version number, for cases like SMAPI itself where the version is only known later during parsing.</summary> + /// <param name="version">The new mod version.</param> + public void OverrideVersion(string version) + { + this.Version = version; + } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs index dae91d84..6951e434 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs @@ -1,6 +1,5 @@ -#nullable disable - using System; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Web.Framework.LogParsing.Models { @@ -14,31 +13,32 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models ** Metadata ****/ /// <summary>Whether the log file was successfully parsed.</summary> + [MemberNotNullWhen(true, nameof(ParsedLog.RawText))] public bool IsValid { get; set; } /// <summary>An error message indicating why the log file is invalid.</summary> - public string Error { get; set; } + public string? Error { get; set; } /// <summary>The raw log text.</summary> - public string RawText { get; set; } + public string? RawText { get; set; } /**** ** Log data ****/ /// <summary>The SMAPI version.</summary> - public string ApiVersion { get; set; } + public string? ApiVersion { get; set; } /// <summary>The game version.</summary> - public string GameVersion { get; set; } + public string? GameVersion { get; set; } /// <summary>The player's operating system.</summary> - public string OperatingSystem { get; set; } + public string? OperatingSystem { get; set; } /// <summary>The game install path.</summary> - public string GamePath { get; set; } + public string? GamePath { get; set; } /// <summary>The mod folder path.</summary> - public string ModPath { get; set; } + public string? ModPath { get; set; } /// <summary>The ISO 8601 timestamp when the log was started.</summary> public DateTimeOffset Timestamp { get; set; } @@ -47,6 +47,6 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models public LogModInfo[] Mods { get; set; } = Array.Empty<LogModInfo>(); /// <summary>The log messages.</summary> - public LogMessage[] Messages { get; set; } + public LogMessage[] Messages { get; set; } = Array.Empty<LogMessage>(); } } diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs index c768a08c..d7e4d810 100644 --- a/src/SMAPI.Web/ViewModels/LogParserModel.cs +++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs @@ -1,9 +1,9 @@ -#nullable disable - using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; +using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Web.Framework.LogParsing.Models; @@ -23,40 +23,41 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// <summary>The paste ID.</summary> - public string PasteID { get; set; } + public string? PasteID { get; } /// <summary>The viewer's detected OS, if known.</summary> - public Platform? DetectedPlatform { get; set; } + public Platform? DetectedPlatform { get; } /// <summary>The parsed log info.</summary> - public ParsedLog ParsedLog { get; set; } + public ParsedLog? ParsedLog { get; private set; } /// <summary>Whether to show the raw unparsed log.</summary> - public bool ShowRaw { get; set; } + public bool ShowRaw { get; private set; } /// <summary>A non-blocking warning while uploading the log.</summary> - public string UploadWarning { get; set; } + public string? UploadWarning { get; set; } /// <summary>An error which occurred while uploading the log.</summary> - public string UploadError { get; set; } + public string? UploadError { get; set; } /// <summary>An error which occurred while parsing the log file.</summary> - public string ParseError => this.ParsedLog?.Error; + public string? ParseError => this.ParsedLog?.Error; /// <summary>When the uploaded file will no longer be available.</summary> public DateTime? Expiry { get; set; } + /// <summary>Whether parsed log data is available.</summary> + [MemberNotNullWhen(true, nameof(LogParserModel.PasteID), nameof(LogParserModel.ParsedLog))] + public bool HasLog => this.ParsedLog != null; + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public LogParserModel() { } - - /// <summary>Construct an instance.</summary> /// <param name="pasteID">The paste ID.</param> /// <param name="platform">The viewer's detected OS, if known.</param> - public LogParserModel(string pasteID, Platform? platform) + public LogParserModel(string? pasteID, Platform? platform) { this.PasteID = pasteID; this.DetectedPlatform = platform; @@ -64,6 +65,26 @@ namespace StardewModdingAPI.Web.ViewModels this.ShowRaw = false; } + /// <summary>Construct an instance.</summary> + /// <param name="pasteId">The paste ID.</param> + /// <param name="detectedPlatform">The viewer's detected OS, if known.</param> + /// <param name="parsedLog">The parsed log info.</param> + /// <param name="showRaw">Whether to show the raw unparsed log.</param> + /// <param name="uploadWarning">A non-blocking warning while uploading the log.</param> + /// <param name="uploadError">An error which occurred while uploading the log.</param> + /// <param name="expiry">When the uploaded file will no longer be available.</param> + [JsonConstructor] + public LogParserModel(string pasteId, Platform? detectedPlatform, ParsedLog? parsedLog, bool showRaw, string? uploadWarning, string? uploadError, DateTime? expiry) + { + this.PasteID = pasteId; + this.DetectedPlatform = detectedPlatform; + this.ParsedLog = parsedLog; + this.ShowRaw = showRaw; + this.UploadWarning = uploadWarning; + this.UploadError = uploadError; + this.Expiry = expiry; + } + /// <summary>Set the log parser result.</summary> /// <param name="parsedLog">The parsed log info.</param> /// <param name="showRaw">Whether to show the raw unparsed log.</param> @@ -79,14 +100,14 @@ namespace StardewModdingAPI.Web.ViewModels public IDictionary<string, LogModInfo[]> GetContentPacksByMod() { // get all mods & content packs - LogModInfo[] mods = this.ParsedLog?.Mods; + LogModInfo[]? mods = this.ParsedLog?.Mods; if (mods == null || !mods.Any()) return new Dictionary<string, LogModInfo[]>(); // group by mod return mods .Where(mod => mod.IsContentPack) - .GroupBy(mod => mod.ContentPackFor) + .GroupBy(mod => mod.ContentPackFor!) .ToDictionary(group => group.Key, group => group.ToArray()); } diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index c26ec230..11f15403 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -1,7 +1,3 @@ -@{ - #nullable disable -} - @using Humanizer @using StardewModdingAPI.Toolkit.Utilities @using StardewModdingAPI.Web.Framework @@ -12,17 +8,24 @@ @{ ViewData["Title"] = "SMAPI log parser"; - ParsedLog log = Model!.ParsedLog; + ParsedLog? log = Model!.ParsedLog; IDictionary<string, LogModInfo[]> contentPacks = Model.GetContentPacksByMod(); IDictionary<string, bool> defaultFilters = Enum - .GetValues(typeof(LogLevel)) - .Cast<LogLevel>() + .GetValues<LogLevel>() .ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace); + IDictionary<int, string> logLevels = Enum + .GetValues<LogLevel>() + .ToDictionary(level => (int)level, level => level.ToString().ToLower()); + + IDictionary<int, string> logSections = Enum + .GetValues<LogSection>() + .ToDictionary(section => (int)section, section => section.ToString()); + string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true); - ISet<int> screenIds = new HashSet<int>(log?.Messages?.Select(p => p.ScreenId) ?? Array.Empty<int>()); + ISet<int> screenIds = new HashSet<int>(log?.Messages.Select(p => p.ScreenId) ?? Array.Empty<int>()); } @section Head { @@ -35,23 +38,53 @@ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3/dist/css/tabby-ui-vertical.min.css" /> <script src="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3" crossorigin="anonymous"></script> - <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> <script src="~/Content/js/file-upload.js"></script> <script src="~/Content/js/log-parser.js"></script> + + <script id="serializedData" type="application/json"> + @if (!Model.ShowRaw) + { + <text> + { + "messages": @this.ForJson(log?.Messages), + "sections": @this.ForJson(logSections), + "logLevels": @this.ForJson(logLevels), + "modSlugs": @this.ForJson(log?.Mods.DistinctBy(p => p.Name).Select(p => new {p.Name, Slug = Model.GetSlug(p.Name)}).Where(p => p.Name != p.Slug).ToDictionary(p => p.Name, p => p.Slug)), + "screenIds": @this.ForJson(screenIds) + } + </text> + } + else + { + <text> + { + "messages": [], + "sections": {}, + "logLevels": {}, + "modSlugs": {}, + "screenIds": [] + } + </text> + } + </script> + <script> $(function() { - smapi.logParser({ - logStarted: new Date(@this.ForJson(log?.Timestamp)), - showPopup: @this.ForJson(log == null), - showMods: @this.ForJson(log?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, _ => true)), - showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, _ => false)), - showLevels: @this.ForJson(defaultFilters), - enableFilters: @this.ForJson(!Model.ShowRaw), - screenIds: @this.ForJson(screenIds) - }, '@this.Url.PlainAction("Index", "LogParser", values: null)'); + smapi.logParser( + { + logStarted: new Date(@this.ForJson(log?.Timestamp)), + dataElement: "script#serializedData", + showPopup: @this.ForJson(log == null), + showMods: @this.ForJson(log?.Mods.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, _ => true)), + showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, _ => false)), + showLevels: @this.ForJson(defaultFilters), + enableFilters: @this.ForJson(!Model.ShowRaw) + } + ); - new Tabby('[data-tabs]'); + new Tabby("[data-tabs]"); }); </script> } @@ -192,12 +225,12 @@ else if (log?.IsValid == true) Consider updating these mods to fix problems: <table id="updates" class="table"> - @foreach (LogModInfo mod in log.Mods.Where(mod => (mod.HasUpdate && !mod.IsContentPack) || (contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList) && contentPackList.Any(pack => pack.HasUpdate)))) + @foreach (LogModInfo mod in log.Mods.Where(mod => (mod.HasUpdate && !mod.IsContentPack) || (contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList) && contentPackList.Any(pack => pack.HasUpdate)))) { <tr class="mod-entry"> <td> <strong class=@(!mod.HasUpdate ? "hidden" : "")>@mod.Name</strong> - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList)) + @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList)) { <div class="content-packs"> @foreach (LogModInfo contentPack in contentPackList.Where(pack => pack.HasUpdate)) @@ -275,29 +308,34 @@ else if (log?.IsValid == true) <span class="notice txt"><i>click any mod to filter</i></span> <span class="notice btn txt" v-on:click="showAllMods" v-bind:class="{ invisible: !anyModsHidden }">show all</span> <span class="notice btn txt" v-on:click="hideAllMods" v-bind:class="{ invisible: !anyModsShown || !anyModsHidden }">hide all</span> + <span class="notice btn txt" v-on:click="toggleContentPacks">toggle content packs in list</span> } </caption> @foreach (var mod in log.Mods.Where(p => p.Loaded && !p.IsContentPack)) { + if (contentPacks == null || !contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList)) + contentPackList = null; + <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-bind:class="{ invisible: !anyModsHidden }" /></td> - <td v-pre> - <strong>@mod.Name</strong> @mod.Version - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList)) + <td> + <strong v-pre>@mod.Name</strong> @mod.Version + @if (contentPackList != null) { - <div class="content-packs"> + <div v-if="!hideContentPacks" class="content-packs"> @foreach (var contentPack in contentPackList) { <text>+ @contentPack.Name @contentPack.Version</text><br /> } </div> + <span v-else class="content-packs-collapsed"> (+ @contentPackList.Length content packs)</span> } </td> - <td v-pre> + <td> @mod.Author - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out contentPackList)) + @if (contentPackList != null) { - <div class="content-packs"> + <div v-if="!hideContentPacks" class="content-packs"> @foreach (var contentPack in contentPackList) { <text>+ @contentPack.Author</text><br /> @@ -323,57 +361,64 @@ else if (log?.IsValid == true) @if (!Model.ShowRaw) { + <div id="filterHolder"></div> <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 class="toggles"> + <div> + Filter messages: + </div> + <div> + <span role="button" v-bind:class="{ active: showLevels['trace'] }" v-on:click="toggleLevel('trace')">TRACE</span> | + <span role="button" v-bind:class="{ active: showLevels['debug'] }" v-on:click="toggleLevel('debug')">DEBUG</span> | + <span role="button" v-bind:class="{ active: showLevels['info'] }" v-on:click="toggleLevel('info')">INFO</span> | + <span role="button" v-bind:class="{ active: showLevels['alert'] }" v-on:click="toggleLevel('alert')">ALERT</span> | + <span role="button" v-bind:class="{ active: showLevels['warn'] }" v-on:click="toggleLevel('warn')">WARN</span> | + <span role="button" v-bind:class="{ active: showLevels['error'] }" v-on:click="toggleLevel('error')">ERROR</span> + <div class="filter-text"> + <input + type="text" + v-bind:class="{ active: !!filterText }" + v-on:input="updateFilterText" + placeholder="search to filter log..." + /> + <span role="button" v-bind:class="{ active: filterUseRegex }" v-on:click="toggleFilterUseRegex" title="Use regular expression syntax.">.*</span> + <span role="button" v-bind:class="{ active: !filterInsensitive }" v-on:click="toggleFilterInsensitive" title="Match exact capitalization only.">aA</span> + <span role="button" v-bind:class="{ active: filterUseWord, 'whole-word': true }" v-on:click="toggleFilterWord" title="Match whole word only."><i>“ ”</i></span> + <span role="button" v-bind:class="{ active: shouldHighlight }" v-on:click="toggleHighlight" title="Highlight matches in the log text.">HL</span> + </div> + <filter-stats + v-bind:start="start" + v-bind:end="end" + v-bind:pages="totalPages" + v-bind:filtered="filteredMessages.length" + v-bind:total="totalMessages" + /> + </div> + </div> + <pager + v-bind:page="page" + v-bind:pages="totalPages" + v-bind:prevPage="prevPage" + v-bind:nextPage="nextPage" + /> </div> - <table id="log"> - @foreach (var message in log.Messages) - { - string levelStr = message.Level.ToString().ToLower(); - string sectionStartClass = message.IsStartOfSection ? "section-start" : null; - string sectionFilter = message.Section != null && !message.IsStartOfSection ? $"&& sectionsAllow('{message.Section}')" : null; // filter the message by section if applicable + <noscript> + <div> + This website uses JavaScript to display a filterable table. To view this log, please enable JavaScript or <a href="@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID, format = LogViewFormat.RawView })">view the raw log</a>. + </div> + <br/> + </noscript> - <tr class="mod @levelStr @sectionStartClass" - @if (message.IsStartOfSection) { <text> v-on:click="toggleSection('@message.Section')" </text> } - v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter"> - <td v-pre>@message.Time</td> - @if (screenIds.Count > 1) - { - <td v-pre>screen_@message.ScreenId</td> - } - <td v-pre>@message.Level.ToString().ToUpper()</td> - <td v-pre data-title="@message.Mod">@message.Mod</td> - <td> - <span v-pre class="log-message-text">@message.Text</span> - @if (message.IsStartOfSection) - { - <span class="section-toggle-message"> - <template v-if="sectionsAllow('@message.Section')"> - This section is shown. Click here to hide it. - </template> - <template v-else> - This section is hidden. Click here to show it. - </template> - </span> - } - </td> - </tr> - if (message.Repeated > 0) - { - <tr class="@levelStr mod mod-repeat" v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter"> - <td colspan="4"></td> - <td v-pre><i>repeats [@message.Repeated] times.</i></td> - </tr> - } - } - </table> + <log-table> + <log-line + v-for="msg in visibleMessages" + v-bind:key="msg.id" + v-bind:showScreenId="showScreenId" + v-bind:message="msg" + v-bind:highlight="shouldHighlight" + /> + </log-table> } else { diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index 8c3acceb..41b54e11 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -113,7 +113,7 @@ table caption { } .table tr { - background: #eee + background: #eee; } #mods span.notice { @@ -148,18 +148,74 @@ table caption { font-style: italic; } +.table .content-packs-collapsed { + opacity: 0.75; + font-size: 0.9em; + font-style: italic; +} + #metadata td:first-child { padding-right: 5px; } .table tr:nth-child(even) { - background: #fff + background: #fff; } #filters { margin: 1em 0 0 0; - padding: 0; + padding: 0 0 0.5em; + display: flex; + justify-content: space-between; + width: calc(100vw - 16em); +} + +#filters > div { + align-self: center; +} + +#filters .toggles { + display: flex; +} + +#filters .toggles > div:first-child { font-weight: bold; + padding: 0.2em 1em 0 0; +} + +#filters .filter-text { + margin-top: 0.5em; +} + +#filters .stats { + margin-top: 0.5em; + font-size: 0.75em; +} + +#filters.sticky { + position: fixed; + top: 0; + left: 0em; + background: #fff; + margin: 0; + padding: 0.5em; + width: calc(100% - 1em); +} + +@media (min-width: 1020px) and (max-width: 1199px) { + #filters:not(.sticky) { + width: calc(100vw - 13em); + } +} + +@media (max-width: 1019px) { + #filters:not(.sticky) { + width: calc(100vw - 3em); + } + + #filters { + display: block; + } } #filters span { @@ -173,6 +229,17 @@ table caption { color: #000; border-color: #880000; background-color: #fcc; + + user-select: none; +} + +#filters .filter-text span { + padding: 3px 0.5em; +} + +#filters .whole-word i { + padding: 0 1px; + border: 1px dashed; } #filters span:hover { @@ -188,11 +255,48 @@ table caption { background: #efe; } +#filters .pager { + margin-top: 0.5em; + text-align: right; +} + +#filters .pager div { + margin-top: 0.5em; +} + +#filters .pager div span { + padding: 0 0.5em; + margin: 0 1px; +} + +#filters .pager span { + background-color: #eee; + border-color: #888; +} + +#filters .pager span.active { + font-weight: bold; + border-color: transparent; + background: transparent; + cursor: unset; +} + +#filters .pager span.disabled { + opacity: 0.3; + cursor: unset; +} + +#filters .pager span:not(.disabled):hover { + background-color: #fff; +} + + /********* ** Log *********/ #log .mod-repeat { font-size: 0.85em; + font-style: italic; } #log .trace { @@ -237,6 +341,11 @@ table caption { white-space: pre-wrap; } +#log .log-message-text strong { + background-color: yellow; + font-weight: normal; +} + #log { border-spacing: 0; } diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 90715375..59c6026c 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -1,20 +1,160 @@ -/* globals $ */ +/* globals $, Vue */ +/** + * The global SMAPI module. + */ var smapi = smapi || {}; + +/** + * The Vue app for the current page. + * @type {Vue} + */ var app; -smapi.logParser = function (data, sectionUrl) { + +// Use a scroll event to apply a sticky effect to the filters / pagination +// bar. We can't just use "position: sticky" due to how the page is structured +// but this works well enough. +$(function () { + let sticking = false; + + document.addEventListener("scroll", function () { + const filters = document.getElementById("filters"); + const holder = document.getElementById("filterHolder"); + if (!filters || !holder) + return; + + const offset = holder.offsetTop; + const shouldStick = window.pageYOffset > offset; + if (shouldStick === sticking) + return; + + sticking = shouldStick; + if (sticking) { + holder.style.marginBottom = `calc(1em + ${filters.offsetHeight}px)`; + filters.classList.add("sticky"); + } + else { + filters.classList.remove("sticky"); + holder.style.marginBottom = ""; + } + }); +}); + +/** + * Initialize a log parser view on the current page. + * @param {object} state The state options to use. + * @returns {void} + */ +smapi.logParser = function (state) { + if (!state) + state = {}; + + // internal helpers + const helpers = { + /** + * Get a handler which invokes the callback after a set delay, resetting the delay each time it's called. + * @param {(...*) => void} action The callback to invoke when the delay ends. + * @param {number} delay The number of milliseconds to delay the action after each call. + * @returns {() => void} + */ + getDebouncedHandler(action, delay) { + let timeoutId = null; + + return function () { + clearTimeout(timeoutId); + + const args = arguments; + const self = this; + + timeoutId = setTimeout( + function () { + action.apply(self, args); + }, + delay + ); + } + }, + + /** + * Escape regex special characters in the given string. + * @param {string} text + * @returns {string} + */ + escapeRegex(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }, + + /** + * Format a number for the user's locale. + * @param {number} value The number to format. + * @returns {string} + */ + formatNumber(value) { + const formatter = window.Intl && Intl.NumberFormat && new Intl.NumberFormat(); + return formatter && formatter.format + ? formatter.format(value) + : `${value}`; + } + }; + + // internal event handlers + const handlers = { + /** + * Method called when the user clicks a log line to toggle the visibility of a section. Binding methods is problematic with functional components so we just use the `data-section` parameter and our global reference to the app. + * @param {any} event + * @returns {false} + */ + clickLogLine(event) { + app.toggleSection(event.currentTarget.dataset.section); + event.preventDefault(); + return false; + }, + + /** + * Navigate to the previous page of messages in the log. + * @returns {void} + */ + prevPage() { + app.prevPage(); + }, + + /** + * Navigate to the next page of messages in the log. + * @returns {void} + */ + nextPage() { + app.nextPage(); + }, + + /** + * Handle a click on a page number element. + * @param {number | Event} event + * @returns {void} + */ + changePage(event) { + if (typeof event === "number") + app.changePage(event); + else if (event) { + const page = parseInt(event.currentTarget.dataset.page); + if (!isNaN(page) && isFinite(page)) + app.changePage(page); + } + } + }; + // internal filter counts - var stats = data.stats = { + const stats = state.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]) + for (let key in state.showMods) { + if (state.showMods.hasOwnProperty(key)) { + if (state.showMods[key]) stats.modsShown++; else stats.modsHidden++; @@ -22,35 +162,607 @@ smapi.logParser = function (data, sectionUrl) { } } + // load raw log data + { + const dataElement = document.querySelector(state.dataElement); + state.data = JSON.parse(dataElement.textContent.trim()); + dataElement.remove(); // let browser unload the data element since we won't need it anymore + } + + // preprocess data for display + state.messages = state.data.messages || []; + if (state.messages.length) { + const levels = state.data.logLevels; + const sections = state.data.sections; + const modSlugs = state.data.modSlugs; + + for (let i = 0, length = state.messages.length; i < length; i++) { + const message = state.messages[i]; + + // add unique ID + message.id = i; + + // add display values + message.LevelName = levels[message.Level]; + message.SectionName = sections[message.Section]; + message.ModSlug = modSlugs[message.Mod] || message.Mod; + + // For repeated messages, since our <log-line /> component + // can't return two rows, just insert a second message + // which will display as the message repeated notice. + if (message.Repeated > 0 && !message.isRepeated) { + const repeatNote = { + id: i + 1, + Level: message.Level, + Section: message.Section, + Mod: message.Mod, + Repeated: message.Repeated, + isRepeated: true + }; + + state.messages.splice(i + 1, 0, repeatNote); + length++; + } + + // let Vue know the message won't change, so it doesn't need to monitor it + Object.freeze(message); + } + } + Object.freeze(state.messages); + // set local time started - if (data) - data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2); + if (state.logStarted) + state.localTimeStarted = ("0" + state.logStarted.getHours()).slice(-2) + ":" + ("0" + state.logStarted.getMinutes()).slice(-2); + + // add the properties we're passing to Vue + state.totalMessages = state.messages.length; + state.filterText = ""; + state.filterRegex = ""; + state.showContentPacks = true; + state.useHighlight = true; + state.useRegex = false; + state.useInsensitive = true; + state.useWord = false; + state.perPage = 1000; + state.page = 1; + + // load saved values, if any + if (localStorage.settings) { + try { + const saved = JSON.parse(localStorage.settings); + + state.showContentPacks = saved.showContentPacks ?? state.showContentPacks; + state.useHighlight = saved.useHighlight ?? state.useHighlight; + state.useRegex = saved.useRegex ?? state.useRegex; + state.useInsensitive = saved.useInsensitive ?? state.useInsensitive; + state.useWord = saved.useWord ?? state.useWord; + } + catch (error) { + // ignore settings if invalid + } + } + + // add a number formatter so our numbers look nicer + Vue.filter("number", handlers.formatNumber); + + // Strictly speaking, we don't need this. However, due to the way our + // Vue template is living in-page the browser is "helpful" and moves + // our <log-line />s outside of a basic <table> since obviously they + // aren't table rows and don't belong inside a table. By using another + // Vue component, we avoid that. + Vue.component("log-table", { + functional: true, + render: function (createElement, context) { + return createElement( + "table", + { + attrs: { + id: "log" + } + }, + context.children + ); + } + }); + + // The <filter-stats /> component draws a nice message under the filters + // telling a user how many messages match their filters, and also expands + // on how many of them they're seeing because of pagination. + Vue.component("filter-stats", { + functional: true, + render: function (createElement, context) { + const props = context.props; + if (props.pages > 1) { + return createElement( + "div", + { class: "stats" }, + [ + "showing ", + createElement("strong", helpers.formatNumber(props.start + 1)), + " to ", + createElement("strong", helpers.formatNumber(props.end)), + " of ", + createElement("strong", helpers.formatNumber(props.filtered)), + " (total: ", + createElement("strong", helpers.formatNumber(props.total)), + ")" + ] + ); + } + + return createElement( + "div", + { class: "stats" }, + [ + "showing ", + createElement("strong", helpers.formatNumber(props.filtered)), + " out of ", + createElement("strong", helpers.formatNumber(props.total)) + ] + ); + } + }); + + // Next up we have <pager /> which renders the pagination list. This has a + // helper method to make building the list of links easier. + function addPageLink(page, links, visited, createElement, currentPage) { + if (visited.has(page)) + return; + + if (page > 1 && !visited.has(page - 1)) + links.push(" … "); + + visited.add(page); + links.push(createElement( + "span", + { + class: page === currentPage ? "active" : null, + attrs: { + "data-page": page + }, + on: { + click: handlers.changePage + } + }, + helpers.formatNumber(page) + )); + } + + Vue.component("pager", { + functional: true, + render: function (createElement, context) { + const props = context.props; + if (props.pages <= 1) + return null; + + const visited = new Set(); + const pageLinks = []; + + for (let i = 1; i <= 2; i++) + addPageLink(i, pageLinks, visited, createElement, props.page); + + for (let i = props.page - 2; i <= props.page + 2; i++) { + if (i >= 1 && i <= props.pages) + addPageLink(i, pageLinks, visited, createElement, props.page); + } + + for (let i = props.pages - 2; i <= props.pages; i++) { + if (i >= 1) + addPageLink(i, pageLinks, visited, createElement, props.page); + } + + return createElement( + "div", + { class: "pager" }, + [ + createElement( + "span", + { + class: props.page <= 1 ? "disabled" : null, + on: { + click: handlers.prevPage + } + }, + "Prev" + ), + " ", + "Page ", + helpers.formatNumber(props.page), + " of ", + helpers.formatNumber(props.pages), + " ", + createElement( + "span", + { + class: props.page >= props.pages ? "disabled" : null, + on: { + click: handlers.nextPage + } + }, + "Next" + ), + createElement("div", {}, pageLinks) + ] + ); + } + }); + + // Our <log-line /> functional component draws each log line. + Vue.component("log-line", { + functional: true, + props: { + showScreenId: { + type: Boolean, + required: true + }, + message: { + type: Object, + required: true + }, + highlight: { + type: Boolean, + required: false + } + }, + render: function (createElement, context) { + const message = context.props.message; + const level = message.LevelName; + + if (message.isRepeated) + return createElement( + "tr", + { + class: [ + "mod", + level, + "mod-repeat" + ] + }, + [ + createElement( + "td", + { + attrs: { + colspan: context.props.showScreenId ? 4 : 3 + } + }, + "" + ), + createElement("td", `repeats ${message.Repeated} times`) + ] + ); + + const events = {}; + let toggleMessage; + if (message.IsStartOfSection) { + const visible = message.SectionName && window.app && app.sectionsAllow(message.SectionName); + events.click = handlers.clickLogLine; + toggleMessage = visible + ? "This section is shown. Click here to hide it." + : "This section is hidden. Click here to show it."; + } + + let text = message.Text; + const filter = window.app && app.filterRegex; + if (text && filter && context.props.highlight) { + text = []; + let match; + let consumed = 0; + let index = 0; + filter.lastIndex = -1; + + // Our logic to highlight the text is a bit funky because we + // want to group consecutive matches to avoid a situation + // where a ton of single characters are in their own elements + // if the user gives us bad input. + + while (true) { + match = filter.exec(message.Text); + if (!match) + break; + + // Do we have an area of non-matching text? This + // happens if the new match's index is further + // along than the last index. + if (match.index > index) { + // Alright, do we have a previous match? If + // we do, we need to consume some text. + if (consumed < index) + text.push(createElement("strong", {}, message.Text.slice(consumed, index))); + + text.push(message.Text.slice(index, match.index)); + consumed = match.index; + } + + index = match.index + match[0].length; + } + + // Add any trailing text after the last match was found. + if (consumed < message.Text.length) { + if (consumed < index) + text.push(createElement("strong", {}, message.Text.slice(consumed, index))); + + if (index < message.Text.length) + text.push(message.Text.slice(index)); + } + } + + return createElement( + "tr", + { + class: [ + "mod", + level, + message.IsStartOfSection ? "section-start" : null + ], + attrs: { + "data-section": message.SectionName + }, + on: events + }, + [ + createElement("td", message.Time), + context.props.showScreenId ? createElement("td", message.ScreenId) : null, + createElement("td", level.toUpperCase()), + createElement( + "td", + { + attrs: { + "data-title": message.Mod + } + }, + message.Mod + ), + createElement( + "td", + [ + createElement( + "span", + { class: "log-message-text" }, + text + ), + message.IsStartOfSection + ? createElement( + "span", + { class: "section-toggle-message" }, + [ + " ", + toggleMessage + ] + ) + : null + ] + ) + ] + ); + } + }); // init app app = new Vue({ - el: '#output', - data: data, + el: "#output", + data: state, computed: { anyModsHidden: function () { return stats.modsHidden > 0; }, anyModsShown: function () { return stats.modsShown > 0; + }, + showScreenId: function () { + return this.data.screenIds.length > 1; + }, + + // Maybe not strictly necessary, but the Vue template is being + // weird about accessing data entries on the app rather than + // computed properties. + hideContentPacks: function () { + return !state.showContentPacks; + }, + + // Filter messages for visibility. + filterUseRegex: function () { + return state.useRegex; + }, + filterInsensitive: function () { + return state.useInsensitive; + }, + filterUseWord: function () { + return state.useWord; + }, + shouldHighlight: function () { + return state.useHighlight; + }, + + filteredMessages: function () { + if (!state.messages) + return []; + + const start = performance.now(); + const filtered = []; + + // This is slightly faster than messages.filter(), which is + // important when working with absolutely huge logs. + for (let i = 0, length = state.messages.length; i < length; i++) { + const msg = state.messages[i]; + if (msg.SectionName && !msg.IsStartOfSection && !this.sectionsAllow(msg.SectionName)) + continue; + + if (!this.filtersAllow(msg.ModSlug, msg.LevelName)) + continue; + + const text = msg.Text || (i > 0 ? state.messages[i - 1].Text : null); + + if (this.filterRegex) { + this.filterRegex.lastIndex = -1; + if (!text || !this.filterRegex.test(text)) + continue; + } + else if (this.filterText && (!text || text.indexOf(this.filterText) === -1)) + continue; + + filtered.push(msg); + } + + const end = performance.now(); + //console.log(`applied ${(this.filterRegex ? "regex" : "text")} filter '${this.filterRegex || this.filterText}' in ${end - start}ms`); + + return filtered; + }, + + // And the rest are about pagination. + start: function () { + return (this.page - 1) * state.perPage; + }, + end: function () { + return this.start + this.visibleMessages.length; + }, + totalPages: function () { + return Math.ceil(this.filteredMessages.length / state.perPage); + }, + // + visibleMessages: function () { + if (this.totalPages <= 1) + return this.filteredMessages; + + const start = this.start; + const end = start + state.perPage; + + return this.filteredMessages.slice(start, end); } }, + created: function () { + window.addEventListener("popstate", () => this.loadFromUrl()); + this.loadFromUrl(); + }, methods: { + // Mostly I wanted people to know they can override the PerPage + // message count with a URL parameter, but we can read Page too. + // In the future maybe we should read *all* filter state so a + // user can link to their exact page state for someone else? + loadFromUrl: function () { + const params = new URL(location).searchParams; + if (params.has("PerPage")) { + const perPage = parseInt(params.get("PerPage")); + if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) + state.perPage = perPage; + } + + if (params.has("Page")) { + const page = parseInt(params.get("Page")); + if (!isNaN(page) && isFinite(page) && page > 0) + this.page = page; + } + }, + toggleLevel: function (id) { - if (!data.enableFilters) + if (!state.enableFilters) return; this.showLevels[id] = !this.showLevels[id]; }, + toggleContentPacks: function () { + state.showContentPacks = !state.showContentPacks; + this.saveSettings(); + }, + + toggleFilterUseRegex: function () { + state.useRegex = !state.useRegex; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleFilterInsensitive: function () { + state.useInsensitive = !state.useInsensitive; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleFilterWord: function () { + state.useWord = !state.useWord; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleHighlight: function () { + state.useHighlight = !state.useHighlight; + this.saveSettings(); + }, + + prevPage: function () { + if (this.page <= 1) + return; + this.page--; + this.updateUrl(); + }, + + nextPage: function () { + if (this.page >= this.totalPages) + return; + this.page++; + this.updateUrl(); + }, + + changePage: function (page) { + if (page < 1 || page > this.totalPages) + return; + this.page = page; + this.updateUrl(); + }, + + // Persist settings into localStorage for use the next time + // the user opens a log. + saveSettings: function () { + localStorage.settings = JSON.stringify({ + showContentPacks: state.showContentPacks, + useRegex: state.useRegex, + useInsensitive: state.useInsensitive, + useWord: state.useWord, + useHighlight: state.useHighlight + }); + }, + + // Whenever the page is changed, replace the current page URL. Using + // replaceState rather than pushState to avoid filling the tab history + // with tons of useless history steps the user probably doesn't + // really care about. + updateUrl: function () { + const url = new URL(location); + url.searchParams.set("Page", state.page); + url.searchParams.set("PerPage", state.perPage); + + window.history.replaceState(null, document.title, url.toString()); + }, + + // We don't want to update the filter text often, so use a debounce with + // a quarter second delay. We basically always build a regular expression + // since we use it for highlighting, and it also make case insensitivity + // much easier. + updateFilterText: helpers.getDebouncedHandler( + function () { + let text = this.filterText = document.querySelector("input[type=text]").value; + if (!text || !text.length) { + this.filterText = ""; + this.filterRegex = null; + } + else { + if (!state.useRegex) + text = helpers.escapeRegex(text); + this.filterRegex = new RegExp( + state.useWord ? `\\b${text}\\b` : text, + state.useInsensitive ? "ig" : "g" + ); + } + }, + 250 + ), + toggleMod: function (id) { - if (!data.enableFilters) + if (!state.enableFilters) return; - var curShown = this.showMods[id]; + const curShown = this.showMods[id]; // first filter: only show this by default if (stats.modsHidden === 0) { @@ -70,17 +782,17 @@ smapi.logParser = function (data, sectionUrl) { }, toggleSection: function (name) { - if (!data.enableFilters) + if (!state.enableFilters) return; this.showSections[name] = !this.showSections[name]; }, showAllMods: function () { - if (!data.enableFilters) + if (!state.enableFilters) return; - for (var key in this.showMods) { + for (let key in this.showMods) { if (this.showMods.hasOwnProperty(key)) { this.showMods[key] = true; } @@ -89,10 +801,10 @@ smapi.logParser = function (data, sectionUrl) { }, hideAllMods: function () { - if (!data.enableFilters) + if (!state.enableFilters) return; - for (var key in this.showMods) { + for (let key in this.showMods) { if (this.showMods.hasOwnProperty(key)) { this.showMods[key] = false; } @@ -113,7 +825,7 @@ smapi.logParser = function (data, sectionUrl) { /********** ** Upload form *********/ - var input = $("#input"); + const input = $("#input"); if (input.length) { // file upload smapi.fileUpload({ diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.sln.DotSettings index 5b35c615..5cb13525 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -31,6 +31,8 @@ <s:Boolean x:Key="/Default/UserDictionary/Words/=craftables/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=crossplatform/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=cutscene/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=debounce/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=debounced/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=decoratable/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=devs/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=fallbacks/@EntryIndexedValue">True</s:Boolean> |