diff options
Diffstat (limited to 'src/SMAPI.Web')
-rw-r--r-- | src/SMAPI.Web/Views/LogParser/Index.cshtml | 27 | ||||
-rw-r--r-- | src/SMAPI.Web/Views/Shared/_Layout.cshtml | 2 | ||||
-rw-r--r-- | src/SMAPI.Web/wwwroot/Content/css/main.css | 26 | ||||
-rw-r--r-- | src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 282 |
4 files changed, 259 insertions, 78 deletions
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 2cf7d1e7..dfb603f2 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -77,18 +77,38 @@ 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)), + showMods: @this.ForJson(log?.Mods.Where(p => p.Loaded && !p.IsContentPack).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]"); + @if (log == null) + { + <text> + new Tabby("[data-tabs]"); + </text> + } }); </script> } +@* quick navigation links *@ +@section SidebarExtra { + @if (log != null) + { + <nav id="quickNav"> + <h4>Scroll to...</h4> + <ul> + <li><a href="#content">Top</a></li> + <li><a href="#filterHolder">Log start</a></li> + <li><a href="#footer">Bottom</a></li> + </ul> + </nav> + } +} + @* upload result banner *@ @if (Model.UploadError != null) { @@ -378,6 +398,7 @@ else if (log?.IsValid == true) <input type="text" v-bind:class="{ active: !!filterText }" + v-model="filterText" v-on:input="updateFilterText" placeholder="search to filter log..." /> @@ -390,7 +411,7 @@ else if (log?.IsValid == true) v-bind:start="start" v-bind:end="end" v-bind:pages="totalPages" - v-bind:filtered="filteredMessages.length" + v-bind:filtered="filteredMessages.total" v-bind:total="totalMessages" /> </div> diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 67dcd3b3..1e82ab5f 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -26,6 +26,8 @@ <li><a href="@Url.PlainAction("Index", "LogParser", values: null)">Log parser</a></li> <li><a href="@Url.PlainAction("Index", "JsonValidator", values: null)">JSON validator</a></li> </ul> + + @RenderSection("SidebarExtra", required: false) </div> <div id="content-column"> <div id="content"> diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css index dcc7a798..a0a407d8 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/main.css +++ b/src/SMAPI.Web/wwwroot/Content/css/main.css @@ -97,6 +97,22 @@ a { margin-left: 1em; } +/* quick navigation */ + +#quickNav { + position: fixed; + bottom: 3em; + width: 12em; +} + +@media (max-height: 400px) { + #quickNav { + position: unset; + width: auto; + } +} + + /* footer */ #footer { margin: 1em; @@ -111,11 +127,16 @@ a { /* mobile fixes */ @media (min-width: 1020px) and (max-width: 1199px) { + #quickNav, #sidebar { width: 7em; background: none; } + #quickNav h4 { + width: unset; + } + #content-column { left: 7em; } @@ -138,4 +159,9 @@ a { top: inherit; left: inherit; } + + #quickNav { + position: unset; + width: auto; + } } diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 59c6026c..c730309b 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -94,6 +94,50 @@ smapi.logParser = function (state) { return formatter && formatter.format ? formatter.format(value) : `${value}`; + }, + + /** + * Try parsing the value as a base-10 integer. + * @param {string} value The value to parse. + * @param {number} defaultValue The value to return if parsing fails. + * @param {() => boolean} criteria An optional callback to check whether a parsed number is valid. + * @returns {number} The parsed number if it's valid, else the default value. + */ + tryParseNumber(value, defaultValue, criteria = null) { + value = parseInt(value, 10); + return !isNaN(value) && isFinite(value) && (!criteria || criteria(value)) + ? value + : defaultValue; + }, + + /** + * Get whether two objects are equivalent based on their top-level properties. + * @param {Object} left The first value to compare. + * @param {Object} right The second value to compare. + * @returns {Boolean} + */ + shallowEquals(left, right) { + if (typeof left !== "object" || typeof right !== "object") + return left === right; + + if (left == null || right == null) + return left == null && right == null; + + if (Array.isArray(left) !== Array.isArray(right)) + return false; + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + + if (leftKeys.length != rightKeys.length) + return false; + + for (const key of leftKeys) { + if (!rightKeys.includes(key) || left[key] !== right[key]) + return false; + } + + return true; } }; @@ -148,20 +192,6 @@ smapi.logParser = function (state) { modsHidden: 0 }; - function updateModFilters() { - // counts - stats.modsShown = 0; - stats.modsHidden = 0; - for (let key in state.showMods) { - if (state.showMods.hasOwnProperty(key)) { - if (state.showMods[key]) - stats.modsShown++; - else - stats.modsHidden++; - } - } - } - // load raw log data { const dataElement = document.querySelector(state.dataElement); @@ -226,6 +256,10 @@ smapi.logParser = function (state) { state.perPage = 1000; state.page = 1; + state.defaultMods = { ...state.showMods }; + state.defaultSections = { ...state.showSections }; + state.defaultLevels = { ...state.showLevels }; + // load saved values, if any if (localStorage.settings) { try { @@ -272,32 +306,29 @@ smapi.logParser = function (state) { 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)) + createElement( + "abbr", + { + attrs: { + title: "These numbers may be inaccurate when using filtering with sections collapsed." + } + }, + [ + "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)), + ")" ] ); } @@ -578,34 +609,39 @@ smapi.logParser = function (state) { if (!state.messages) return []; - const start = performance.now(); + //const start = performance.now(); const filtered = []; + let total = 0; + // 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) { + const text = msg.Text || (i > 0 ? state.messages[i - 1].Text : null); this.filterRegex.lastIndex = -1; if (!text || !this.filterRegex.test(text)) continue; } - else if (this.filterText && (!text || text.indexOf(this.filterText) === -1)) + + total++; + + if (msg.SectionName && !msg.IsStartOfSection && !this.sectionsAllow(msg.SectionName)) continue; filtered.push(msg); } - const end = performance.now(); - //console.log(`applied ${(this.filterRegex ? "regex" : "text")} filter '${this.filterRegex || this.filterText}' in ${end - start}ms`); + filtered.total = total; + + Object.freeze(filtered); + + //const end = performance.now(); + //console.log(`applied ${(this.useRegex ? "regex" : "text")} filter '${this.filterRegex}' in ${end - start}ms`); return filtered; }, @@ -636,23 +672,109 @@ smapi.logParser = function (state) { 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; + + state.perPage = helpers.tryParseNumber(params.get("PerPage"), 1000, n => n > 0); + this.page = helpers.tryParseNumber(params.get("Page"), 1, n => n > 0); + state.filterText = params.get("Filter") || ""; + + if (params.has("FilterMode")) { + const values = params.get("FilterMode").split("~"); + state.useRegex = values.includes("Regex"); + state.useInsensitive = !values.includes("Sensitive"); + state.useWord = values.includes("Word"); + } + else { + state.useRegex = false; + state.useInsensitive = true; + state.useWord = false; } - if (params.has("Page")) { - const page = parseInt(params.get("Page")); - if (!isNaN(page) && isFinite(page) && page > 0) - this.page = page; + if (params.has("Mods")) { + const value = params.get("Mods").split("~"); + for (const key of Object.keys(this.showMods)) + this.showMods[key] = value.includes(key); + + } + else { + for (const key of Object.keys(this.showMods)) + this.showMods[key] = state.defaultMods[key]; + } + + if (params.has("Levels")) { + const values = params.get("Levels").split("~"); + for (const key of Object.keys(this.showLevels)) + this.showLevels[key] = values.includes(key); + + } + else { + const keys = Object.keys(this.showLevels); + for (const key of Object.keys(this.showLevels)) + this.showLevels[key] = state.defaultLevels[key]; + } + + if (params.has("Sections")) { + const values = params.get("Sections").split("~"); + for (const key of Object.keys(this.showSections)) + this.showSections[key] = values.includes(key); + + } + else { + for (const key of Object.keys(this.showSections)) + this.showSections[key] = state.defaultSections[key]; + } + + this.updateModFilters(); + this.updateFilterText(); + }, + + /** + * Update the page URL to track non-default filter values. + */ + updateUrl: function () { + const url = new URL(location); + url.searchParams.set("Page", state.page); + url.searchParams.set("PerPage", state.perPage); + + if (!helpers.shallowEquals(this.showMods, state.defaultMods)) + url.searchParams.set("Mods", Object.entries(this.showMods).filter(p => p[1]).map(p => p[0]).join("~")); + else + url.searchParams.delete("Mods"); + + if (!helpers.shallowEquals(this.showLevels, state.defaultLevels)) + url.searchParams.set("Levels", Object.entries(this.showLevels).filter(p => p[1]).map(p => p[0]).join("~")); + else + url.searchParams.delete("Levels"); + + if (!helpers.shallowEquals(this.showSections, state.defaultSections)) + url.searchParams.set("Sections", Object.entries(this.showSections).filter(p => p[1]).map(p => p[0]).join("~")); + else + url.searchParams.delete("Sections"); + + if (state.filterText?.length) { + url.searchParams.set("Filter", state.filterText); + + const modes = []; + if (state.useRegex) + modes.push("Regex"); + if (!state.useInsensitive) + modes.push("Sensitive"); + if (state.useWord) + modes.push("Word"); + + if (modes.length) + url.searchParams.set("FilterMode", modes.join("~")); + else + url.searchParams.delete("FilterMode"); + + } + else { + url.searchParams.delete("Filter"); + url.searchParams.delete("FilterMode"); } + + window.history.replaceState(null, document.title, url.toString()); // use replaceState instead of pushState to avoid filling the tab history with history steps the user probably doesn't care about }, toggleLevel: function (id) { @@ -660,6 +782,7 @@ smapi.logParser = function (state) { return; this.showLevels[id] = !this.showLevels[id]; + this.updateUrl(); }, toggleContentPacks: function () { @@ -711,8 +834,9 @@ smapi.logParser = function (state) { this.updateUrl(); }, - // Persist settings into localStorage for use the next time - // the user opens a log. + /** + * Persist settings into localStorage for use the next time the user opens a log. + */ saveSettings: function () { localStorage.settings = JSON.stringify({ showContentPacks: state.showContentPacks, @@ -723,25 +847,13 @@ smapi.logParser = function (state) { }); }, - // 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; + let text = state.filterText; if (!text || !text.length) { this.filterText = ""; this.filterRegex = null; @@ -754,10 +866,26 @@ smapi.logParser = function (state) { state.useInsensitive ? "ig" : "g" ); } + + this.updateUrl(); }, 250 ), + updateModFilters: function () { + // counts + stats.modsShown = 0; + stats.modsHidden = 0; + for (let key in state.showMods) { + if (state.showMods.hasOwnProperty(key)) { + if (state.showMods[key]) + stats.modsShown++; + else + stats.modsHidden++; + } + } + }, + toggleMod: function (id) { if (!state.enableFilters) return; @@ -778,7 +906,8 @@ smapi.logParser = function (state) { else this.showMods[id] = !this.showMods[id]; - updateModFilters(); + this.updateModFilters(); + this.updateUrl(); }, toggleSection: function (name) { @@ -786,6 +915,7 @@ smapi.logParser = function (state) { return; this.showSections[name] = !this.showSections[name]; + this.updateUrl(); }, showAllMods: function () { @@ -797,7 +927,8 @@ smapi.logParser = function (state) { this.showMods[key] = true; } } - updateModFilters(); + this.updateModFilters(); + this.updateUrl(); }, hideAllMods: function () { @@ -809,7 +940,8 @@ smapi.logParser = function (state) { this.showMods[key] = false; } } - updateModFilters(); + this.updateModFilters(); + this.updateUrl(); }, filtersAllow: function (modId, level) { |