From 077d8e4f401ad1806c6af0540f432366314a2300 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 6 Apr 2022 18:25:00 -0400 Subject: remove some unused/redundant code --- src/SMAPI.Web/wwwroot/Content/css/file-upload.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/SMAPI.Web/wwwroot/Content/css') diff --git a/src/SMAPI.Web/wwwroot/Content/css/file-upload.css b/src/SMAPI.Web/wwwroot/Content/css/file-upload.css index ff170691..f29d46aa 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/file-upload.css +++ b/src/SMAPI.Web/wwwroot/Content/css/file-upload.css @@ -11,7 +11,7 @@ border-radius: 5px; border: 1px solid #000088; outline: none; - box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2); + box-shadow: inset 0 0 1px 1px rgba(0, 0, 192, .2); } #submit { -- cgit From 0beff189d19416dfcbb64bce800af41de37ccd08 Mon Sep 17 00:00:00 2001 From: Khloe Leclair Date: Fri, 8 Apr 2022 14:59:52 -0400 Subject: Implement client-side log rendering, better filtering, and pagination to improve performance and enhance the user experience with using the log parser. --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 143 +++-- src/SMAPI.Web/wwwroot/Content/css/log-parser.css | 109 +++- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 632 ++++++++++++++++++++++- 3 files changed, 827 insertions(+), 57 deletions(-) (limited to 'src/SMAPI.Web/wwwroot/Content/css') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index c26ec230..39a2da0f 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -20,6 +20,16 @@ .Cast() .ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace); + IDictionary logLevels = Enum + .GetValues(typeof(LogLevel)) + .Cast() + .ToDictionary(level => (int)level, level => level.ToString().ToLower()); + + IDictionary logSections = Enum + .GetValues(typeof(LogSection)) + .Cast() + .ToDictionary(section => (int)section, section => section.ToString()); + string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true); ISet screenIds = new HashSet(log?.Messages?.Select(p => p.ScreenId) ?? Array.Empty()); @@ -34,8 +44,15 @@ + @if (!Model.ShowRaw) + { + + + + + } - + @@ -275,29 +292,35 @@ else if (log?.IsValid == true) click any mod to filter show all hide all + toggle content packs } @foreach (var mod in log.Mods.Where(p => p.Loaded && !p.IsContentPack)) { + LogModInfo[]? contentPackList; + if (contentPacks == null || !contentPacks.TryGetValue(mod.Name, out contentPackList)) + contentPackList = null; + - - @mod.Name @mod.Version - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList)) + + @mod.Name @mod.Version + @if (contentPackList != null) { -
+
@foreach (var contentPack in contentPackList) { + @contentPack.Name @contentPack.Version
}
+ (+ @contentPackList.Length Content Packs) } - + @mod.Author - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out contentPackList)) + @if (contentPackList != null) { -
+
@foreach (var contentPack in contentPackList) { + @contentPack.Author
@@ -323,57 +346,67 @@ else if (log?.IsValid == true) @if (!Model.ShowRaw) { +
- Filter messages: - TRACE | - DEBUG | - INFO | - ALERT | - WARN | - ERROR +
+
+ Filter messages: +
+
+ TRACE | + DEBUG | + INFO | + ALERT | + WARN | + ERROR +
+ + .* + aA + Ab + HL +
+ +
+
+
- - @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 + - v-on:click="toggleSection('@message.Section')" } - v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter"> - - @if (screenIds.Count > 1) - { - - } - - - - - if (message.Repeated > 0) - { - - - - - } - } -
@message.Timescreen_@message.ScreenId@message.Level.ToString().ToUpper()@message.Mod - @message.Text - @if (message.IsStartOfSection) - { - - - - - } -
repeats [@message.Repeated] times.
+ + + } 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..94bc049b 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -148,6 +148,10 @@ table caption { font-style: italic; } +.content-packs--short { + opacity: 0.75; +} + #metadata td:first-child { padding-right: 5px; } @@ -158,8 +162,58 @@ table caption { #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 +227,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 +253,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 +339,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..c16b237a 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -2,12 +2,98 @@ var smapi = smapi || {}; var app; +var messages; + + +// Necessary helper method for updating our text filter in a performant way. +// Wouldn't want to update it for every individually typed character. +function debounce(fn, delay) { + var timeoutID = null + return function () { + clearTimeout(timeoutID) + var args = arguments + var that = this + timeoutID = setTimeout(function () { + fn.apply(that, args) + }, delay) + } +} + +// Case insensitive text searching and match word searching is best done in +// regex, so if the user isn't trying to use regex, escape their input. +function escapeRegex(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// 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 (event) { + const filters = document.getElementById('filters'); + const holder = document.getElementById('filterHolder'); + if (!filters || !holder) + return; + + const offset = holder.offsetTop; + const should_stick = window.pageYOffset > offset; + if (should_stick === sticking) + return; + + sticking = should_stick; + if (sticking) { + holder.style.marginBottom = `calc(1em + ${filters.offsetHeight}px)`; + filters.classList.add('sticky'); + } else { + filters.classList.remove('sticky'); + holder.style.marginBottom = ''; + } + }); +}); + +// This method is called when we click 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. +smapi.clickLogLine = function (event) { + app.toggleSection(event.currentTarget.dataset.section); + event.preventDefault(); + return false; +} + +// And these methods are called when doing pagination. Just makes things +// easier, so may as well use helpers. +smapi.prevPage = function () { + app.prevPage(); +} + +smapi.nextPage = function () { + app.nextPage(); +} + +smapi.changePage = function (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); + } +} + + smapi.logParser = function (data, sectionUrl) { + if (!data) + data = {}; + // internal filter counts var stats = data.stats = { modsShown: 0, modsHidden: 0 }; + function updateModFilters() { // counts stats.modsShown = 0; @@ -22,10 +108,357 @@ smapi.logParser = function (data, sectionUrl) { } } + // load our data + + // Rather than pre-rendering the list elements into the document, we read + // a lot of JSON and use Vue to build the list. This is a lot more + // performant and easier on memory.Our JSON is stored in special script + // tags, that we later remove to let the browser clean up even more memory. + let nodeParsedMessages = document.querySelector('script#parsedMessages'); + if (nodeParsedMessages) { + messages = JSON.parse(nodeParsedMessages.textContent) || []; + const logLevels = JSON.parse(document.querySelector('script#logLevels').textContent) || {}; + const logSections = JSON.parse(document.querySelector('script#logSections').textContent) || {}; + const modSlugs = JSON.parse(document.querySelector('script#modSlugs').textContent) || {}; + + // Remove all references to the script tags and remove them from the + // DOM so that the browser can clean them up. + nodeParsedMessages.remove(); + document.querySelector('script#logLevels').remove(); + document.querySelector('script#logSections').remove(); + document.querySelector('script#modSlugs').remove(); + nodeParsedMessages = null; + + // Pre-process the messages since they aren't quite serialized in + // the format we want. We also want to freeze every last message + // so that Vue won't install its change listening behavior. + for (let i = 0, length = messages.length; i < length; i++) { + const msg = messages[i]; + msg.id = i; + msg.LevelName = logLevels && logLevels[msg.Level]; + msg.SectionName = logSections && logSections[msg.Section]; + msg.ModSlug = modSlugs && modSlugs[msg.Mod] || msg.Mod; + + // For repeated messages, since our component + // can't return two rows, just insert a second message + // which will display as the message repeated notice. + if (msg.Repeated > 0 && ! msg.isRepeated) { + const second = { + id: i + 1, + Level: msg.Level, + Section: msg.Section, + Mod: msg.Mod, + Repeated: msg.Repeated, + isRepeated: true, + }; + + messages.splice(i + 1, 0, second); + length++; + } + + Object.freeze(msg); + } + + Object.freeze(messages); + + } else + messages = []; + // set local time started - if (data) + if (data.logStarted) data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2); + // Add some properties to the data we're passing to Vue. + data.totalMessages = messages.length; + + data.filterText = ''; + data.filterRegex = ''; + + data.showContentPacks = true; + data.useHighlight = true; + data.useRegex = false; + data.useInsensitive = true; + data.useWord = false; + + data.perPage = 1000; + data.page = 1; + + // Now load these values. + if (localStorage.settings) { + try { + const saved = JSON.parse(localStorage.settings); + if (saved.hasOwnProperty('showContentPacks')) + data.showContentPacks = saved.showContentPacks; + if (saved.hasOwnProperty('useHighlight')) + dat.useHighlight = saved.useHighlight; + if (saved.hasOwnProperty('useRegex')) + data.useRegex = saved.useRegex; + if (saved.hasOwnProperty('useInsensitive')) + data.useInsensitive = saved.useInsensitive; + if (saved.hasOwnProperty('useWord')) + data.useWord = saved.useWord; + } catch { /* ignore errors */ } + } + + // This would be easier if we could just use JSX but this project doesn't + // have a proper JavaScript build environment and I really don't feel + // like setting one up. + + // Add a number formatter so that our numbers look nicer. + const fmt = window.Intl && Intl.NumberFormat && new Intl.NumberFormat(); + function formatNumber(value) { + if (!fmt || !fmt.format) return `${value}`; + return fmt.format(value); + } + Vue.filter('number', 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 s outside of a basic 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 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', formatNumber(props.start + 1)), + ' to ', + createElement('strong', formatNumber(props.end)), + ' of ', + createElement('strong', formatNumber(props.filtered)), + ' (total: ', + createElement('strong', formatNumber(props.total)), + ')' + ]); + + return createElement('div', { + class: 'stats' + }, [ + 'showing ', + createElement('strong', formatNumber(props.filtered)), + ' out of ', + createElement('strong', formatNumber(props.total)) + ]); + } + }); + + // Next up we have 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: smapi.changePage + } + }, 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) + continue; + + addPageLink(i, pageLinks, visited, createElement, props.page); + } + + for (let i = props.pages - 2; i <= props.pages; i++) { + if (i < 1) + continue; + + addPageLink(i, pageLinks, visited, createElement, props.page); + } + + return createElement('div', { + class: 'pager' + }, [ + createElement('span', { + class: props.page <= 1 ? 'disabled' : null, + on: { + click: smapi.prevPage + } + }, 'Prev'), + ' ', + 'Page ', + formatNumber(props.page), + ' of ', + formatNumber(props.pages), + ' ', + createElement('span', { + class: props.page >= props.pages ? 'disabled' : null, + on: { + click: smapi.nextPage + } + }, 'Next'), + createElement('div', {}, pageLinks) + ]); + } + }); + + // Our functional component draws each log line. + Vue.component('log-line', { + functional: true, + props: { + showScreenId: { + type: Boolean, + required: true + }, + message: { + type: Object, + required: true + }, + sectionExpanded: { + type: Boolean, + required: false + }, + highlight: { + type: Boolean, + required: false + } + }, + render: function (createElement, context) { + const msg = context.props.message; + const level = msg.LevelName; + + if (msg.isRepeated) + return createElement('tr', { + class: [ + "mod", + level, + "mod-repeat" + ] + }, [ + createElement('td', { + attrs: { + colspan: context.props.showScreenId ? 4 : 3 + } + }, ''), + createElement('td', `repeats ${msg.Repeated} times`) + ]); + + const events = {}; + let toggleMessage; + if (msg.IsStartOfSection) { + const visible = context.props.sectionExpanded; + events.click = smapi.clickLogLine; + toggleMessage = visible ? + 'This section is shown. Click here to hide it.' : + 'This section is hidden. Click here to show it.'; + } + + let text = msg.Text; + const filter = window.app && app.filterRegex; + if (text && filter && context.props.highlight) { + text = []; + let match, consumed = 0, idx = 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 (match = filter.exec(msg.Text)) { + // 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 > idx) { + // Alright, do we have a previous match? If + // we do, we need to consume some text. + if (consumed < idx) + text.push(createElement('strong', {}, msg.Text.slice(consumed, idx))); + + text.push(msg.Text.slice(idx, match.index)); + consumed = match.index; + } + + idx = match.index + match[0].length; + } + + // Add any trailing text after the last match was found. + if (consumed < msg.Text.length) { + if (consumed < idx) + text.push(createElement('strong', {}, msg.Text.slice(consumed, idx))); + + if (idx < msg.Text.length) + text.push(msg.Text.slice(idx)); + } + } + + return createElement('tr', { + class: [ + "mod", + level, + msg.IsStartOfSection ? "section-start" : null + ], + attrs: { + 'data-section': msg.SectionName + }, + on: events + }, [ + createElement('td', msg.Time), + context.props.showScreenId ? createElement('td', msg.ScreenId) : null, + createElement('td', level.toUpperCase()), + createElement('td', { + attrs: { + 'data-title': msg.Mod + } + }, msg.Mod), + createElement('td', [ + createElement('span', { + class: 'log-message-text' + }, text), + msg.IsStartOfSection ? createElement('span', { + class: 'section-toggle-message' + }, [ + ' ', + toggleMessage + ]) : null + ]) + ]); + } + }); + // init app app = new Vue({ el: '#output', @@ -36,9 +469,114 @@ smapi.logParser = function (data, sectionUrl) { }, anyModsShown: function () { return stats.modsShown > 0; + }, + showScreenId: function () { + return this.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. + visibleSections: function () { + const ret = []; + for (const [k, v] of Object.entries(this.showSections)) + if (v !== false) + ret.push(k); + return ret; + }, + hideContentPacks: function () { + return !data.showContentPacks; + }, + + // Filter messages for visibility. + filterUseRegex: function () { return data.useRegex; }, + filterInsensitive: function () { return data.useInsensitive; }, + filterUseWord: function () { return data.useWord; }, + shouldHighlight: function () { return data.useHighlight; }, + + filteredMessages: function () { + if (!messages) + return []; + + const start = performance.now(); + const ret = []; + + // This is slightly faster than messages.filter(), which is + // important when working with absolutely huge logs. + for (let i = 0, length = messages.length; i < length; i++) { + const msg = messages[i]; + if (msg.SectionName && !msg.IsStartOfSection && !this.sectionsAllow(msg.SectionName)) + continue; + + if (!this.filtersAllow(msg.ModSlug, msg.LevelName)) + continue; + + let text = msg.Text || (i > 0 ? 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; + + ret.push(msg); + } + + const end = performance.now(); + console.log(`filter took ${end - start}ms`); + + return ret; + }, + + // And the rest are about pagination. + start: function () { + return (this.page - 1) * data.perPage; + }, + end: function () { + return this.start + this.visibleMessages.length; + }, + totalPages: function () { + return Math.ceil(this.filteredMessages.length / data.perPage); + }, + // + visibleMessages: function () { + if (this.totalPages <= 1) + return this.filteredMessages; + + const start = this.start; + const end = start + data.perPage; + + return this.filteredMessages.slice(start, end); } }, + created: function () { + this.loadFromUrl = this.loadFromUrl.bind(this); + 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')) + try { + const perPage = parseInt(params.get('PerPage')); + if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) + data.perPage = perPage; + } catch { /* ignore errors */ } + + if (params.has('Page')) + try { + const page = parseInt(params.get('Page')); + if (!isNaN(page) && isFinite(page) && page > 0) + this.page = page; + } catch { /* ignore errors */ } + }, + toggleLevel: function (id) { if (!data.enableFilters) return; @@ -46,6 +584,98 @@ smapi.logParser = function (data, sectionUrl) { this.showLevels[id] = !this.showLevels[id]; }, + toggleContentPacks: function () { + data.showContentPacks = !data.showContentPacks; + this.saveSettings(); + }, + + toggleFilterUseRegex: function () { + data.useRegex = !data.useRegex; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleFilterInsensitive: function () { + data.useInsensitive = !data.useInsensitive; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleFilterWord: function () { + data.useWord = !data.useWord; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleHighlight: function () { + data.useHighlight = !data.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: data.showContentPacks, + useRegex: data.useRegex, + useInsensitive: data.useInsensitive, + useWord: data.useWord, + useHighlight: data.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', data.page); + url.searchParams.set('PerPage', data.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: debounce(function () { + let text = this.filterText = document.querySelector('input[type=text]').value; + if (!text || !text.length) { + this.filterText = ''; + this.filterRegex = null; + } else { + if (!data.useRegex) + text = escapeRegex(text); + this.filterRegex = new RegExp( + data.useWord ? `\\b${text}\\b` : text, + data.useInsensitive ? 'ig' : 'g' + ); + } + }, 250), + toggleMod: function (id) { if (!data.enableFilters) return; -- cgit From 26d29a1070e00b4edeaf3334d4c4d072d52a56ff Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 15:44:17 -0400 Subject: minor refactoring --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 23 +- src/SMAPI.Web/wwwroot/Content/css/log-parser.css | 8 +- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 629 +++++++++++++---------- src/SMAPI.sln.DotSettings | 2 + 4 files changed, 382 insertions(+), 280 deletions(-) (limited to 'src/SMAPI.Web/wwwroot/Content/css') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index a7552888..2d5dd403 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -68,8 +68,7 @@ showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, _ => false)), showLevels: @this.ForJson(defaultFilters), enableFilters: @this.ForJson(!Model.ShowRaw) - }, - "@this.Url.PlainAction("Index", "LogParser", values: null)" + } ); new Tabby("[data-tabs]"); @@ -296,7 +295,7 @@ else if (log?.IsValid == true) click any mod to filter show all hide all - toggle content packs + toggle content packs in list } @foreach (var mod in log.Mods.Where(p => p.Loaded && !p.IsContentPack)) @@ -316,7 +315,7 @@ else if (log?.IsValid == true) + @contentPack.Name @contentPack.Version
} - (+ @contentPackList.Length Content Packs) + (+ @contentPackList.Length content packs) }
@@ -365,14 +364,14 @@ else if (log?.IsValid == true)
- .* - aA - Ab - HL + .* + aA + “ ” + HL
- This website uses JavaScript to display a filterable table. To view this log, please either - view the raw log - or enable JavaScript. + This website uses JavaScript to display a filterable table. To view this log, please enable JavaScript or view the raw log.

diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index 94bc049b..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,8 +148,10 @@ table caption { font-style: italic; } -.content-packs--short { +.table .content-packs-collapsed { opacity: 0.75; + font-size: 0.9em; + font-style: italic; } #metadata td:first-child { @@ -157,7 +159,7 @@ table caption { } .table tr:nth-child(even) { - background: #fff + background: #fff; } #filters { diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index af7ceb1e..72cb4a11 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -1,29 +1,15 @@ -/* globals $ */ +/* globals $, Vue */ +/** + * The global SMAPI module. + */ var smapi = smapi || {}; -var app; -var messages; - - -// Necessary helper method for updating our text filter in a performant way. -// Wouldn't want to update it for every individually typed character. -function debounce(fn, delay) { - var timeoutID = null - return function () { - clearTimeout(timeoutID) - var args = arguments - var that = this - timeoutID = setTimeout(function () { - fn.apply(that, args) - }, delay) - } -} -// Case insensitive text searching and match word searching is best done in -// regex, so if the user isn't trying to use regex, escape their input. -function escapeRegex(text) { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} +/** + * The Vue app for the current page. + * @type {Vue} + */ +var app; // 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 @@ -31,65 +17,133 @@ function escapeRegex(text) { $(function () { let sticking = false; - document.addEventListener("scroll", function (event) { + document.addEventListener("scroll", function () { const filters = document.getElementById("filters"); const holder = document.getElementById("filterHolder"); if (!filters || !holder) return; const offset = holder.offsetTop; - const should_stick = window.pageYOffset > offset; - if (should_stick === sticking) + const shouldStick = window.pageYOffset > offset; + if (shouldStick === sticking) return; - sticking = should_stick; + sticking = shouldStick; if (sticking) { holder.style.marginBottom = `calc(1em + ${filters.offsetHeight}px)`; filters.classList.add("sticky"); - } else { + } + else { filters.classList.remove("sticky"); holder.style.marginBottom = ""; } }); }); -// This method is called when we click 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. -smapi.clickLogLine = function (event) { - app.toggleSection(event.currentTarget.dataset.section); - event.preventDefault(); - return false; -} - -// And these methods are called when doing pagination. Just makes things -// easier, so may as well use helpers. -smapi.prevPage = function () { - app.prevPage(); -} - -smapi.nextPage = function () { - app.nextPage(); -} - -smapi.changePage = function (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); - } -} - - -smapi.logParser = function (state, sectionUrl) { +/** + * 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 = state.stats = { + const stats = state.stats = { modsShown: 0, modsHidden: 0 }; @@ -98,7 +152,7 @@ smapi.logParser = function (state, sectionUrl) { // counts stats.modsShown = 0; stats.modsHidden = 0; - for (var key in state.showMods) { + for (let key in state.showMods) { if (state.showMods.hasOwnProperty(key)) { if (state.showMods[key]) stats.modsShown++; @@ -109,14 +163,14 @@ smapi.logParser = function (state, sectionUrl) { } // preprocess data for display - messages = state.data?.messages || []; - if (messages.length) { + 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 = messages.length; i < length; i++) { - const message = messages[i]; + for (let i = 0, length = state.messages.length; i < length; i++) { + const message = state.messages[i]; // add unique ID message.id = i; @@ -136,10 +190,10 @@ smapi.logParser = function (state, sectionUrl) { Section: message.Section, Mod: message.Mod, Repeated: message.Repeated, - isRepeated: true, + isRepeated: true }; - messages.splice(i + 1, 0, repeatNote); + state.messages.splice(i + 1, 0, repeatNote); length++; } @@ -147,55 +201,42 @@ smapi.logParser = function (state, sectionUrl) { Object.freeze(message); } } - Object.freeze(messages); + Object.freeze(state.messages); // set local time started if (state.logStarted) state.localTimeStarted = ("0" + state.logStarted.getHours()).slice(-2) + ":" + ("0" + state.logStarted.getMinutes()).slice(-2); - // Add some properties to the data we're passing to Vue. - state.totalMessages = messages.length; - + // 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; - // Now load these values. + // load saved values, if any if (localStorage.settings) { try { const saved = JSON.parse(localStorage.settings); - if (saved.hasOwnProperty("showContentPacks")) - state.showContentPacks = saved.showContentPacks; - if (saved.hasOwnProperty("useHighlight")) - dat.useHighlight = saved.useHighlight; - if (saved.hasOwnProperty("useRegex")) - state.useRegex = saved.useRegex; - if (saved.hasOwnProperty("useInsensitive")) - state.useInsensitive = saved.useInsensitive; - if (saved.hasOwnProperty("useWord")) - state.useWord = saved.useWord; - } catch { /* ignore errors */ } - } - // This would be easier if we could just use JSX but this project doesn't - // have a proper JavaScript build environment and I really don't feel - // like setting one up. - - // Add a number formatter so that our numbers look nicer. - const fmt = window.Intl && Intl.NumberFormat && new Intl.NumberFormat(); - function formatNumber(value) { - if (!fmt || !fmt.format) return `${value}`; - return fmt.format(value); + 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 + } } - Vue.filter("number", formatNumber); + + // 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 @@ -205,11 +246,15 @@ smapi.logParser = function (state, sectionUrl) { Vue.component("log-table", { functional: true, render: function (createElement, context) { - return createElement("table", { - attrs: { - id: "log" - } - }, context.children); + return createElement( + "table", + { + attrs: { + id: "log" + } + }, + context.children + ); } }); @@ -220,29 +265,34 @@ smapi.logParser = function (state, sectionUrl) { functional: true, render: function (createElement, context) { const props = context.props; - if (props.pages > 1) - return createElement("div", { - class: "stats" - }, [ + 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", formatNumber(props.start + 1)), - " to ", - createElement("strong", formatNumber(props.end)), - " of ", - createElement("strong", formatNumber(props.filtered)), - " (total: ", - createElement("strong", formatNumber(props.total)), - ")" - ]); - - return createElement("div", { - class: "stats" - }, [ - "showing ", - createElement("strong", formatNumber(props.filtered)), - " out of ", - createElement("strong", formatNumber(props.total)) - ]); + createElement("strong", helpers.formatNumber(props.filtered)), + " out of ", + createElement("strong", helpers.formatNumber(props.total)) + ] + ); } }); @@ -256,15 +306,19 @@ smapi.logParser = function (state, sectionUrl) { links.push(" … "); visited.add(page); - links.push(createElement("span", { - class: page == currentPage ? "active" : null, - attrs: { - "data-page": page + links.push(createElement( + "span", + { + class: page === currentPage ? "active" : null, + attrs: { + "data-page": page + }, + on: { + click: handlers.changePage + } }, - on: { - click: smapi.changePage - } - }, formatNumber(page))); + helpers.formatNumber(page) + )); } Vue.component("pager", { @@ -274,49 +328,55 @@ smapi.logParser = function (state, sectionUrl) { if (props.pages <= 1) return null; - const visited = new Set; + 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) - continue; - - addPageLink(i, pageLinks, visited, createElement, props.page); + 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) - continue; - - addPageLink(i, pageLinks, visited, createElement, props.page); + 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: smapi.prevPage - } - }, "Prev"), - " ", - "Page ", - formatNumber(props.page), - " of ", - formatNumber(props.pages), - " ", - createElement("span", { - class: props.page >= props.pages ? "disabled" : null, - on: { - click: smapi.nextPage - } - }, "Next"), - createElement("div", {}, pageLinks) - ]); + 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) + ] + ); } }); @@ -342,26 +402,34 @@ smapi.logParser = function (state, sectionUrl) { const level = message.LevelName; if (message.isRepeated) - return createElement("tr", { - class: [ - "mod", - level, - "mod-repeat" + return createElement( + "tr", + { + class: [ + "mod", + level, + "mod-repeat" + ] + }, + [ + createElement( + "td", + { + attrs: { + colspan: context.props.showScreenId ? 4 : 3 + } + }, + "" + ), + createElement("td", `repeats ${message.Repeated} times`) ] - }, [ - 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 = smapi.clickLogLine; + events.click = handlers.clickLogLine; toggleMessage = visible ? "This section is shown. Click here to hide it." : "This section is hidden. Click here to show it."; @@ -371,7 +439,9 @@ smapi.logParser = function (state, sectionUrl) { const filter = window.app && app.filterRegex; if (text && filter && context.props.highlight) { text = []; - let match, consumed = 0, idx = 0; + let match; + let consumed = 0; + let index = 0; filter.lastIndex = -1; // Our logic to highlight the text is a bit funky because we @@ -379,64 +449,85 @@ smapi.logParser = function (state, sectionUrl) { // where a ton of single characters are in their own elements // if the user gives us bad input. - while (match = filter.exec(message.Text)) { + 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 > idx) { + if (match.index > index) { // Alright, do we have a previous match? If // we do, we need to consume some text. - if (consumed < idx) - text.push(createElement("strong", {}, message.Text.slice(consumed, idx))); + if (consumed < index) + text.push(createElement("strong", {}, message.Text.slice(consumed, index))); - text.push(message.Text.slice(idx, match.index)); + text.push(message.Text.slice(index, match.index)); consumed = match.index; } - idx = match.index + match[0].length; + index = match.index + match[0].length; } // Add any trailing text after the last match was found. if (consumed < message.Text.length) { - if (consumed < idx) - text.push(createElement("strong", {}, message.Text.slice(consumed, idx))); + if (consumed < index) + text.push(createElement("strong", {}, message.Text.slice(consumed, index))); - if (idx < message.Text.length) - text.push(message.Text.slice(idx)); + 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", { + return createElement( + "tr", + { + class: [ + "mod", + level, + message.IsStartOfSection ? "section-start" : null + ], 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 - ]) - ]); + "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 + ] + ) + ] + ); } }); @@ -463,44 +554,53 @@ smapi.logParser = function (state, sectionUrl) { }, // Filter messages for visibility. - filterUseRegex: function () { return state.useRegex; }, - filterInsensitive: function () { return state.useInsensitive; }, - filterUseWord: function () { return state.useWord; }, - shouldHighlight: function () { return state.useHighlight; }, + filterUseRegex: function () { + return state.useRegex; + }, + filterInsensitive: function () { + return state.useInsensitive; + }, + filterUseWord: function () { + return state.useWord; + }, + shouldHighlight: function () { + return state.useHighlight; + }, filteredMessages: function () { - if (!messages) + if (!state.messages) return []; const start = performance.now(); - const ret = []; + const filtered = []; // This is slightly faster than messages.filter(), which is // important when working with absolutely huge logs. - for (let i = 0, length = messages.length; i < length; i++) { - const msg = messages[i]; + 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; - let text = msg.Text || (i > 0 ? messages[i - 1].Text : null); + 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)) + } + else if (this.filterText && (!text || text.indexOf(this.filterText) === -1)) continue; - ret.push(msg); + filtered.push(msg); } const end = performance.now(); - console.log(`filter took ${end - start}ms`); + //console.log(`applied ${(this.filterRegex ? "regex" : "text")} filter '${this.filterRegex || this.filterText}' in ${end - start}ms`); - return ret; + return filtered; }, // And the rest are about pagination. @@ -525,8 +625,7 @@ smapi.logParser = function (state, sectionUrl) { } }, created: function () { - this.loadFromUrl = this.loadFromUrl.bind(this); - window.addEventListener("popstate", this.loadFromUrl); + window.addEventListener("popstate", () => this.loadFromUrl()); this.loadFromUrl(); }, methods: { @@ -536,19 +635,17 @@ smapi.logParser = function (state, sectionUrl) { // user can link to their exact page state for someone else? loadFromUrl: function () { const params = new URL(location).searchParams; - if (params.has("PerPage")) - try { - const perPage = parseInt(params.get("PerPage")); - if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) - state.perPage = perPage; - } catch { /* ignore errors */ } + if (params.has("PerPage")) { + const perPage = parseInt(params.get("PerPage")); + if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) + state.perPage = perPage; + } - if (params.has("Page")) - try { - const page = parseInt(params.get("Page")); - if (!isNaN(page) && isFinite(page) && page > 0) - this.page = page; - } catch { /* ignore errors */ } + if (params.has("Page")) { + const page = parseInt(params.get("Page")); + if (!isNaN(page) && isFinite(page) && page > 0) + this.page = page; + } }, toggleLevel: function (id) { @@ -635,26 +732,30 @@ smapi.logParser = function (state, sectionUrl) { // 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: debounce(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 = escapeRegex(text); - this.filterRegex = new RegExp( - state.useWord ? `\\b${text}\\b` : text, - state.useInsensitive ? "ig" : "g" - ); - } - }, 250), + 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 (!state.enableFilters) return; - var curShown = this.showMods[id]; + const curShown = this.showMods[id]; // first filter: only show this by default if (stats.modsHidden === 0) { @@ -684,7 +785,7 @@ smapi.logParser = function (state, sectionUrl) { 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; } @@ -696,7 +797,7 @@ smapi.logParser = function (state, sectionUrl) { 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; } @@ -717,7 +818,7 @@ smapi.logParser = function (state, 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 @@ True True True + True + True True True True -- cgit From a21d24f4b7d14701205a6805422de31da84da6ca Mon Sep 17 00:00:00 2001 From: Khloe Leclair Date: Tue, 12 Apr 2022 02:07:21 -0400 Subject: Replace bitfields for state and just use comma-separated strings. Add a note that numbers may be inaccurate if filtering is used when sections are collapsed. Add quick navigation links. --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 13 ++ src/SMAPI.Web/wwwroot/Content/css/main.css | 32 ++++ src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 235 +++++++++---------------- 3 files changed, 124 insertions(+), 156 deletions(-) (limited to 'src/SMAPI.Web/wwwroot/Content/css') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 7aa0fd6b..d95499b7 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -94,6 +94,19 @@ } +@* quick navigation links *@ +@if (log != null) +{ + +} + @* upload result banner *@ @if (Model.UploadError != null) { diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css index dcc7a798..52b304d0 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/main.css +++ b/src/SMAPI.Web/wwwroot/Content/css/main.css @@ -72,6 +72,7 @@ a { color: #666; } +#quickNav h4, #sidebar h4 { margin: 1.5em 0 0.2em 0; width: 10em; @@ -80,11 +81,13 @@ a { font-weight: normal; } +#quickNav a, #sidebar a { color: #77B; border: 0; } +#quickNav ul, #quickNav li, #sidebar ul, #sidebar li { margin: 0; padding: 0; @@ -93,10 +96,29 @@ a { color: #888; } +#quickNav li, #sidebar li { margin-left: 1em; } +/* quick navigation */ + +#quickNav { + position: fixed; + left: 8px; + bottom: 3em; + width: 12em; + color: #666; +} + +@media (max-height: 400px) { + #quickNav { + position: unset; + width: auto; + } +} + + /* footer */ #footer { margin: 1em; @@ -111,11 +133,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 +165,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 8538423f..37c57082 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -97,120 +97,23 @@ smapi.logParser = function (state) { }, /** - * Convert an array of boolean values into a bitmap. - * @param {Boolean[]} value An array of boolean values - * @returns {BigInt} + * Try parsing the value as an integer, in base 10. Return the number + * if it's valid, or return the default value otherwise. + * @param {String} value The value to parse. + * @param {Number} defaultValue The value to return if parsing fails. + * @param {Function} critera An optional criteria to check the number with. */ - toBitmap(value) { - let result = BigInt(0); - if (!Array.isArray(value)) - return value ? BigInt(1) : BigInt(0); - - for (let i = 0; i < value.length; i++) { - if (value[i]) - result += BigInt(2) ** BigInt(value.length - i - 1); + tryNumber(value, defaultValue, critera = null) { + try { + value = parseInt(value, 10); + } catch { + return defaultValue; } - return result; - }, - - /** - * Convert a bitmap into an array of boolean values. - * @param {BigInt} value The bitmap - * @param {Number} length The expected length of the result - * @returns {Boolean[]} - */ - fromBitmap(value, length = -1) { - if (typeof value != "bigint") - value = ""; - else - value = value.toString(2); - - const result = []; - while (length > value.length) { - result.push(false); - length--; - } - - for (let i = 0; i < value.length; i++) { - result.push(value[i] === "1" ? true : false); - } - - return result; - }, - - /** - * Convert a base-64 string to a BigInt. - * @param {string} value - * @returns {BigInt} - */ - b64ToBigInt(value) { - const bin = atob(value); - const hex = []; - - for (let i = 0; i < bin.length; i++) { - let h = bin.charCodeAt(i).toString(16); - if (h.length % 2) h = `0${h}`; - hex.push(h); - } - - return BigInt(`0x${hex.join('')}`); - }, - - /** - * Convert a BigInt to a base-64 string. - * @param {BigInt} value - * @returns {string} - */ - bigIntTo64(value) { - let hex = value.toString(16); - if (hex.length % 2) hex = `0${hex}`; - - const result = []; - for (let i = 0; i < hex.length; i += 2) { - const val = parseInt(hex.slice(i, i + 2), 16); - result.push(String.fromCharCode(val)); - } - - return btoa(result.join('')); - }, - - /** - * Make a base-64 string URL safe. - * @param {string} value - * @returns {string} - */ - b64ToUrl(value) { - return value.replace(/\//g, '_').replace(/=/g, '-').replace(/\+/g, '.'); - }, - - /** - * Convert a URL safe base-64 string back to normal. - * @param {string} value - * @returns {string} - */ - urlTob64(value) { - return value.replace(/_/g, '/').replace(/-/g, '=').replace(/\./g, '+'); - }, + if (isNaN(value) || !isFinite(value) || (critera && !critera(value))) + return defaultValue; - /** - * Convert an array of booleans to a BigInt bitmap, then convert that - * to a base-64 string, then make it URL safe. - * @param {Boolean[]} value - * @returns {string} - */ - toUrlBitmap(value) { - return helpers.b64ToUrl(helpers.bigIntTo64(helpers.toBitmap(value))); - }, - - /** - * Convert a URL safe base-64 string to a normal base-64 string, convert - * that to a BigInt, and then parse a bitmap from the BigInt. - * @param {string} value - * @param {Number} length The expected length of the bitmap. - */ - fromUrlBitmap(value, length = -1) { - return helpers.fromBitmap(helpers.b64ToBigInt(helpers.urlTob64(value)), length); + return value; }, /** @@ -433,12 +336,18 @@ smapi.logParser = function (state) { "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)), + 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)), ")" @@ -788,64 +697,66 @@ smapi.logParser = function (state) { 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.tryNumber(params.get("PerPage"), 1000, n => n > 0); + } else { + state.perPage = 1000; } if (params.has("Page")) { - const page = parseInt(params.get("Page")); - if (!isNaN(page) && isFinite(page) && page > 0) - this.page = page; + this.page = helpers.tryNumber(params.get("Page"), 1, n => n > 0); + } else { + this.page = 1; } - let updateFilter = false; - - if (params.has("Filter")) { + if (params.has("Filter")) state.filterText = params.get("Filter"); - updateFilter = true; - } + else + state.filterText = ""; if (params.has("FilterMode")) { - const values = helpers.fromUrlBitmap(params.get("FilterMode"), 3); - state.useRegex = values[0]; - state.useInsensitive = values[1]; - state.useWord = values[2]; - updateFilter = true; + 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("Mods")) { - const keys = Object.keys(this.showMods); - const value = params.get("Mods"); - const values = value === "all" ? true : value === "none" ? false : helpers.fromUrlBitmap(value, keys.length); - - for (let i = 0; i < keys.length; i++) { - this.showMods[keys[i]] = Array.isArray(values) ? values[i] : values; - } + const value = params.get("Mods").split("~"); + for (const key of Object.keys(this.showMods)) + this.showMods[key] = value.includes(key); - updateModFilters(); + } else { + for (const key of Object.keys(this.showMods)) + this.showMods[key] = state.defaultMods[key]; } if (params.has("Levels")) { - const keys = Object.keys(this.showLevels); - const values = helpers.fromUrlBitmap(params.get("Levels"), keys.length); + const values = params.get("Levels").split("~"); + for (const key of Object.keys(this.showLevels)) + this.showLevels[key] = values.includes(key); - for (let i = 0; i < keys.length; i++) { - this.showLevels[keys[i]] = values[i]; - } + } 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 keys = Object.keys(this.showSections); - const values = helpers.fromUrlBitmap(params.get("Levels"), keys.length); + const values = params.get("Sections").split("~"); + for (const key of Object.keys(this.showSections)) + this.showSections[key] = values.includes(key); - for (let i = 0; i < keys.length; i++) { - this.showSections[keys[i]] = values[i]; - } + } else { + for (const key of Object.keys(this.showSections)) + this.showSections[key] = state.defaultSections[key]; } - if (updateFilter) - this.updateFilterText(); + updateModFilters(); + this.updateFilterText(); }, // Whenever the page state changed, replace the current page URL. Using @@ -858,23 +769,35 @@ smapi.logParser = function (state) { url.searchParams.set("PerPage", state.perPage); if (!helpers.shallowEquals(this.showMods, state.defaultMods)) - url.searchParams.set("Mods", stats.modsHidden == 0 ? "all" : stats.modsShown == 0 ? "none" : helpers.toUrlBitmap(Object.values(this.showMods))); + url.searchParams.set("Mods", Object.entries(this.showMods).filter(x => x[1]).map(x => x[0]).join("~")); else url.searchParams.delete("Mods"); if (!helpers.shallowEquals(this.showLevels, state.defaultLevels)) - url.searchParams.set("Levels", helpers.toUrlBitmap(Object.values(this.showLevels))); + url.searchParams.set("Levels", Object.entries(this.showLevels).filter(x => x[1]).map(x => x[0]).join("~")); else url.searchParams.delete("Levels"); if (!helpers.shallowEquals(this.showSections, state.defaultSections)) - url.searchParams.set("Sections", helpers.toUrlBitmap(Object.values(this.showSections))); + url.searchParams.set("Sections", Object.entries(this.showSections).filter(x => x[1]).map(x => x[0]).join("~")); else url.searchParams.delete("Sections"); if (state.filterText && state.filterText.length) { url.searchParams.set("Filter", state.filterText); - url.searchParams.set("FilterMode", helpers.toUrlBitmap([state.useRegex, state.useInsensitive, state.useWord])); + 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"); -- cgit From 0b9227564979b3e6e71dbd48ced2a9b7407fd640 Mon Sep 17 00:00:00 2001 From: Khloe Leclair Date: Tue, 12 Apr 2022 02:18:51 -0400 Subject: Make horizontal scrolling with the quick navigation links less bad. Probably need to move them into the actual sidebar element though for proper sorting. --- src/SMAPI.Web/wwwroot/Content/css/log-parser.css | 3 +++ 1 file changed, 3 insertions(+) (limited to 'src/SMAPI.Web/wwwroot/Content/css') diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index 41b54e11..e47a938d 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -13,6 +13,9 @@ caption { #output { padding: 10px; overflow: auto; + z-index: 1; + background: #fff; + position: relative; } #output h2 { -- cgit From 4f54f517ce3d6fd6e87cfee6b0ce61346d62c3e3 Mon Sep 17 00:00:00 2001 From: Khloe Leclair Date: Tue, 12 Apr 2022 13:50:51 -0400 Subject: Use an optional section for rendering quick navigation links on the mod viewer, containing them within the #sidebar element. --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 22 ++++++++++++---------- src/SMAPI.Web/Views/Shared/_Layout.cshtml | 2 ++ src/SMAPI.Web/wwwroot/Content/css/log-parser.css | 3 --- src/SMAPI.Web/wwwroot/Content/css/main.css | 6 ------ 4 files changed, 14 insertions(+), 19 deletions(-) (limited to 'src/SMAPI.Web/wwwroot/Content/css') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index d95499b7..d55bfd4d 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -95,16 +95,18 @@ } @* quick navigation links *@ -@if (log != null) -{ - +@section SidebarExtra { + @if (log != null) + { + + } } @* upload result banner *@ diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 7c86a68c..248cc7ef 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -30,6 +30,8 @@
  • Log parser
  • JSON validator
  • + + @RenderSection("SidebarExtra", required: false)
    diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index e47a938d..41b54e11 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -13,9 +13,6 @@ caption { #output { padding: 10px; overflow: auto; - z-index: 1; - background: #fff; - position: relative; } #output h2 { diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css index 52b304d0..a0a407d8 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/main.css +++ b/src/SMAPI.Web/wwwroot/Content/css/main.css @@ -72,7 +72,6 @@ a { color: #666; } -#quickNav h4, #sidebar h4 { margin: 1.5em 0 0.2em 0; width: 10em; @@ -81,13 +80,11 @@ a { font-weight: normal; } -#quickNav a, #sidebar a { color: #77B; border: 0; } -#quickNav ul, #quickNav li, #sidebar ul, #sidebar li { margin: 0; padding: 0; @@ -96,7 +93,6 @@ a { color: #888; } -#quickNav li, #sidebar li { margin-left: 1em; } @@ -105,10 +101,8 @@ a { #quickNav { position: fixed; - left: 8px; bottom: 3em; width: 12em; - color: #666; } @media (max-height: 400px) { -- cgit From 446205c7bd342422f4b4f14d6c8f36dc0b6cf468 Mon Sep 17 00:00:00 2001 From: Khloe Leclair Date: Sat, 16 Apr 2022 13:55:54 -0400 Subject: Add regex error checking, and display a message to the user when their regular expression has a syntax error. Additionally, use a non-capturing group to surround the user input when `Match whole word` is enabled in case alternates are being used. Finally, add a safety check to highlighting to avoid an infinite loop when zero-length matches happen. --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 6 +++++ src/SMAPI.Web/wwwroot/Content/css/log-parser.css | 7 ++++- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 33 ++++++++++++++++++++---- 3 files changed, 40 insertions(+), 6 deletions(-) (limited to 'src/SMAPI.Web/wwwroot/Content/css') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index dfb603f2..5e55906d 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -406,6 +406,12 @@ else if (log?.IsValid == true) aA “ ” HL +
    + {{ filterError }} +