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/wwwroot/Content/js/log-parser.js | 632 ++++++++++++++++++++++++- 1 file changed, 631 insertions(+), 1 deletion(-) (limited to 'src/SMAPI.Web/wwwroot/Content/js') 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 631d0375c3868cb68d1487662955db4ea1b7dd77 Mon Sep 17 00:00:00 2001 From: Khloe Leclair Date: Fri, 8 Apr 2022 15:26:35 -0400 Subject: Simplify visible section checking by abusing Vue behavior, since the proper way is being buggy. --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 1 - src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 13 +------------ 2 files changed, 1 insertion(+), 13 deletions(-) (limited to 'src/SMAPI.Web/wwwroot/Content/js') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 39a2da0f..8f44b4a2 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -404,7 +404,6 @@ else if (log?.IsValid == true) v-bind:showScreenId="showScreenId" v-bind:message="msg" v-bind:highlight="shouldHighlight" - v-bind:sectionExpanded="msg.SectionName && visibleSections.includes(msg.SectionName)" /> } diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index c16b237a..1984d58f 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -347,10 +347,6 @@ smapi.logParser = function (data, sectionUrl) { type: Object, required: true }, - sectionExpanded: { - type: Boolean, - required: false - }, highlight: { type: Boolean, required: false @@ -379,7 +375,7 @@ smapi.logParser = function (data, sectionUrl) { const events = {}; let toggleMessage; if (msg.IsStartOfSection) { - const visible = context.props.sectionExpanded; + const visible = msg.SectionName && window.app && app.sectionsAllow(msg.SectionName); events.click = smapi.clickLogLine; toggleMessage = visible ? 'This section is shown. Click here to hide it.' : @@ -477,13 +473,6 @@ smapi.logParser = function (data, sectionUrl) { // 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; }, -- cgit From b3519f3cc161f460e56cfc0a0662ec3b5bfb841b Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 12:59:21 -0400 Subject: rename 'data' to 'state' for upcoming changes --- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 106 ++++++++++++------------- 1 file changed, 53 insertions(+), 53 deletions(-) (limited to 'src/SMAPI.Web/wwwroot/Content/js') diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 1984d58f..51d6b53e 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -84,12 +84,12 @@ smapi.changePage = function (event) { } -smapi.logParser = function (data, sectionUrl) { - if (!data) - data = {}; +smapi.logParser = function (state, sectionUrl) { + if (!state) + state = {}; // internal filter counts - var stats = data.stats = { + var stats = state.stats = { modsShown: 0, modsHidden: 0 }; @@ -98,9 +98,9 @@ smapi.logParser = function (data, sectionUrl) { // counts stats.modsShown = 0; stats.modsHidden = 0; - for (var key in data.showMods) { - if (data.showMods.hasOwnProperty(key)) { - if (data.showMods[key]) + for (var key in state.showMods) { + if (state.showMods.hasOwnProperty(key)) { + if (state.showMods[key]) stats.modsShown++; else stats.modsHidden++; @@ -165,38 +165,38 @@ smapi.logParser = function (data, sectionUrl) { messages = []; // set local time started - if (data.logStarted) - 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 some properties to the data we're passing to Vue. - data.totalMessages = messages.length; + state.totalMessages = messages.length; - data.filterText = ''; - data.filterRegex = ''; + state.filterText = ''; + state.filterRegex = ''; - data.showContentPacks = true; - data.useHighlight = true; - data.useRegex = false; - data.useInsensitive = true; - data.useWord = false; + state.showContentPacks = true; + state.useHighlight = true; + state.useRegex = false; + state.useInsensitive = true; + state.useWord = false; - data.perPage = 1000; - data.page = 1; + state.perPage = 1000; + state.page = 1; // Now load these values. if (localStorage.settings) { try { const saved = JSON.parse(localStorage.settings); if (saved.hasOwnProperty('showContentPacks')) - data.showContentPacks = saved.showContentPacks; + state.showContentPacks = saved.showContentPacks; if (saved.hasOwnProperty('useHighlight')) dat.useHighlight = saved.useHighlight; if (saved.hasOwnProperty('useRegex')) - data.useRegex = saved.useRegex; + state.useRegex = saved.useRegex; if (saved.hasOwnProperty('useInsensitive')) - data.useInsensitive = saved.useInsensitive; + state.useInsensitive = saved.useInsensitive; if (saved.hasOwnProperty('useWord')) - data.useWord = saved.useWord; + state.useWord = saved.useWord; } catch { /* ignore errors */ } } @@ -458,7 +458,7 @@ smapi.logParser = function (data, sectionUrl) { // init app app = new Vue({ el: '#output', - data: data, + data: state, computed: { anyModsHidden: function () { return stats.modsHidden > 0; @@ -474,14 +474,14 @@ smapi.logParser = function (data, sectionUrl) { // weird about accessing data entries on the app rather than // computed properties. hideContentPacks: function () { - return !data.showContentPacks; + return !state.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; }, + filterUseRegex: function () { return state.useRegex; }, + filterInsensitive: function () { return state.useInsensitive; }, + filterUseWord: function () { return state.useWord; }, + shouldHighlight: function () { return state.useHighlight; }, filteredMessages: function () { if (!messages) @@ -520,13 +520,13 @@ smapi.logParser = function (data, sectionUrl) { // And the rest are about pagination. start: function () { - return (this.page - 1) * data.perPage; + return (this.page - 1) * state.perPage; }, end: function () { return this.start + this.visibleMessages.length; }, totalPages: function () { - return Math.ceil(this.filteredMessages.length / data.perPage); + return Math.ceil(this.filteredMessages.length / state.perPage); }, // visibleMessages: function () { @@ -534,7 +534,7 @@ smapi.logParser = function (data, sectionUrl) { return this.filteredMessages; const start = this.start; - const end = start + data.perPage; + const end = start + state.perPage; return this.filteredMessages.slice(start, end); } @@ -555,7 +555,7 @@ smapi.logParser = function (data, sectionUrl) { try { const perPage = parseInt(params.get('PerPage')); if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) - data.perPage = perPage; + state.perPage = perPage; } catch { /* ignore errors */ } if (params.has('Page')) @@ -567,37 +567,37 @@ smapi.logParser = function (data, sectionUrl) { }, toggleLevel: function (id) { - if (!data.enableFilters) + if (!state.enableFilters) return; this.showLevels[id] = !this.showLevels[id]; }, toggleContentPacks: function () { - data.showContentPacks = !data.showContentPacks; + state.showContentPacks = !state.showContentPacks; this.saveSettings(); }, toggleFilterUseRegex: function () { - data.useRegex = !data.useRegex; + state.useRegex = !state.useRegex; this.saveSettings(); this.updateFilterText(); }, toggleFilterInsensitive: function () { - data.useInsensitive = !data.useInsensitive; + state.useInsensitive = !state.useInsensitive; this.saveSettings(); this.updateFilterText(); }, toggleFilterWord: function () { - data.useWord = !data.useWord; + state.useWord = !state.useWord; this.saveSettings(); this.updateFilterText(); }, toggleHighlight: function () { - data.useHighlight = !data.useHighlight; + state.useHighlight = !state.useHighlight; this.saveSettings(); }, @@ -626,11 +626,11 @@ smapi.logParser = function (data, sectionUrl) { // 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 + showContentPacks: state.showContentPacks, + useRegex: state.useRegex, + useInsensitive: state.useInsensitive, + useWord: state.useWord, + useHighlight: state.useHighlight }); }, @@ -640,8 +640,8 @@ smapi.logParser = function (data, sectionUrl) { // really care about. updateUrl: function () { const url = new URL(location); - url.searchParams.set('Page', data.page); - url.searchParams.set('PerPage', data.perPage); + url.searchParams.set('Page', state.page); + url.searchParams.set('PerPage', state.perPage); window.history.replaceState(null, document.title, url.toString()); }, @@ -656,17 +656,17 @@ smapi.logParser = function (data, sectionUrl) { this.filterText = ''; this.filterRegex = null; } else { - if (!data.useRegex) + if (!state.useRegex) text = escapeRegex(text); this.filterRegex = new RegExp( - data.useWord ? `\\b${text}\\b` : text, - data.useInsensitive ? 'ig' : 'g' + 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]; @@ -689,14 +689,14 @@ 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) { @@ -708,7 +708,7 @@ smapi.logParser = function (data, sectionUrl) { }, hideAllMods: function () { - if (!data.enableFilters) + if (!state.enableFilters) return; for (var key in this.showMods) { -- cgit From 260dbbf205bfa86c76a999ccd00e01941e9ce469 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 13:02:25 -0400 Subject: standardize quote style --- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 192 ++++++++++++------------- 1 file changed, 96 insertions(+), 96 deletions(-) (limited to 'src/SMAPI.Web/wwwroot/Content/js') diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 51d6b53e..9684834c 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -22,7 +22,7 @@ function debounce(fn, 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, '\\$&'); + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } // Use a scroll event to apply a sticky effect to the filters / pagination @@ -31,9 +31,9 @@ function escapeRegex(text) { $(function () { let sticking = false; - document.addEventListener('scroll', function (event) { - const filters = document.getElementById('filters'); - const holder = document.getElementById('filterHolder'); + document.addEventListener("scroll", function (event) { + const filters = document.getElementById("filters"); + const holder = document.getElementById("filterHolder"); if (!filters || !holder) return; @@ -45,10 +45,10 @@ $(function () { sticking = should_stick; if (sticking) { holder.style.marginBottom = `calc(1em + ${filters.offsetHeight}px)`; - filters.classList.add('sticky'); + filters.classList.add("sticky"); } else { - filters.classList.remove('sticky'); - holder.style.marginBottom = ''; + filters.classList.remove("sticky"); + holder.style.marginBottom = ""; } }); }); @@ -74,7 +74,7 @@ smapi.nextPage = function () { } smapi.changePage = function (event) { - if (typeof event === 'number') + if (typeof event === "number") app.changePage(event); else if (event) { const page = parseInt(event.currentTarget.dataset.page); @@ -114,19 +114,19 @@ smapi.logParser = function (state, sectionUrl) { // 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'); + 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) || {}; + 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(); + 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 @@ -171,8 +171,8 @@ smapi.logParser = function (state, sectionUrl) { // Add some properties to the data we're passing to Vue. state.totalMessages = messages.length; - state.filterText = ''; - state.filterRegex = ''; + state.filterText = ""; + state.filterRegex = ""; state.showContentPacks = true; state.useHighlight = true; @@ -187,15 +187,15 @@ smapi.logParser = function (state, sectionUrl) { if (localStorage.settings) { try { const saved = JSON.parse(localStorage.settings); - if (saved.hasOwnProperty('showContentPacks')) + if (saved.hasOwnProperty("showContentPacks")) state.showContentPacks = saved.showContentPacks; - if (saved.hasOwnProperty('useHighlight')) + if (saved.hasOwnProperty("useHighlight")) dat.useHighlight = saved.useHighlight; - if (saved.hasOwnProperty('useRegex')) + if (saved.hasOwnProperty("useRegex")) state.useRegex = saved.useRegex; - if (saved.hasOwnProperty('useInsensitive')) + if (saved.hasOwnProperty("useInsensitive")) state.useInsensitive = saved.useInsensitive; - if (saved.hasOwnProperty('useWord')) + if (saved.hasOwnProperty("useWord")) state.useWord = saved.useWord; } catch { /* ignore errors */ } } @@ -210,19 +210,19 @@ smapi.logParser = function (state, sectionUrl) { if (!fmt || !fmt.format) return `${value}`; return fmt.format(value); } - Vue.filter('number', formatNumber); + 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', { + Vue.component("log-table", { functional: true, render: function (createElement, context) { - return createElement('table', { + return createElement("table", { attrs: { - id: 'log' + id: "log" } }, context.children); } @@ -231,32 +231,32 @@ smapi.logParser = function (state, sectionUrl) { // 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', { + Vue.component("filter-stats", { functional: true, render: function (createElement, context) { const props = context.props; if (props.pages > 1) - return createElement('div', { - class: 'stats' + 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)), - ')' + "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' + return createElement("div", { + class: "stats" }, [ - 'showing ', - createElement('strong', formatNumber(props.filtered)), - ' out of ', - createElement('strong', formatNumber(props.total)) + "showing ", + createElement("strong", formatNumber(props.filtered)), + " out of ", + createElement("strong", formatNumber(props.total)) ]); } }); @@ -268,13 +268,13 @@ smapi.logParser = function (state, sectionUrl) { return; if (page > 1 && !visited.has(page - 1)) - links.push(' … '); + links.push(" … "); visited.add(page); - links.push(createElement('span', { - class: page == currentPage ? 'active' : null, + links.push(createElement("span", { + class: page == currentPage ? "active" : null, attrs: { - 'data-page': page + "data-page": page }, on: { click: smapi.changePage @@ -282,7 +282,7 @@ smapi.logParser = function (state, sectionUrl) { }, formatNumber(page))); } - Vue.component('pager', { + Vue.component("pager", { functional: true, render: function (createElement, context) { const props = context.props; @@ -309,34 +309,34 @@ smapi.logParser = function (state, sectionUrl) { addPageLink(i, pageLinks, visited, createElement, props.page); } - return createElement('div', { - class: 'pager' + return createElement("div", { + class: "pager" }, [ - createElement('span', { - class: props.page <= 1 ? 'disabled' : null, + createElement("span", { + class: props.page <= 1 ? "disabled" : null, on: { click: smapi.prevPage } - }, 'Prev'), - ' ', - 'Page ', + }, "Prev"), + " ", + "Page ", formatNumber(props.page), - ' of ', + " of ", formatNumber(props.pages), - ' ', - createElement('span', { - class: props.page >= props.pages ? 'disabled' : null, + " ", + createElement("span", { + class: props.page >= props.pages ? "disabled" : null, on: { click: smapi.nextPage } - }, 'Next'), - createElement('div', {}, pageLinks) + }, "Next"), + createElement("div", {}, pageLinks) ]); } }); // Our functional component draws each log line. - Vue.component('log-line', { + Vue.component("log-line", { functional: true, props: { showScreenId: { @@ -357,19 +357,19 @@ smapi.logParser = function (state, sectionUrl) { const level = msg.LevelName; if (msg.isRepeated) - return createElement('tr', { + return createElement("tr", { class: [ "mod", level, "mod-repeat" ] }, [ - createElement('td', { + createElement("td", { attrs: { colspan: context.props.showScreenId ? 4 : 3 } - }, ''), - createElement('td', `repeats ${msg.Repeated} times`) + }, ""), + createElement("td", `repeats ${msg.Repeated} times`) ]); const events = {}; @@ -377,9 +377,9 @@ smapi.logParser = function (state, sectionUrl) { if (msg.IsStartOfSection) { const visible = msg.SectionName && window.app && app.sectionsAllow(msg.SectionName); events.click = smapi.clickLogLine; - toggleMessage = visible ? - 'This section is shown. Click here to hide it.' : - 'This section is hidden. Click here to show it.'; + toggleMessage = visible + ? "This section is shown. Click here to hide it." + : "This section is hidden. Click here to show it."; } let text = msg.Text; @@ -402,7 +402,7 @@ smapi.logParser = function (state, sectionUrl) { // 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(createElement("strong", {}, msg.Text.slice(consumed, idx))); text.push(msg.Text.slice(idx, match.index)); consumed = match.index; @@ -414,40 +414,40 @@ smapi.logParser = function (state, sectionUrl) { // 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))); + text.push(createElement("strong", {}, msg.Text.slice(consumed, idx))); if (idx < msg.Text.length) text.push(msg.Text.slice(idx)); } } - return createElement('tr', { + return createElement("tr", { class: [ "mod", level, msg.IsStartOfSection ? "section-start" : null ], attrs: { - 'data-section': msg.SectionName + "data-section": msg.SectionName }, on: events }, [ - createElement('td', msg.Time), - context.props.showScreenId ? createElement('td', msg.ScreenId) : null, - createElement('td', level.toUpperCase()), - createElement('td', { + createElement("td", msg.Time), + context.props.showScreenId ? createElement("td", msg.ScreenId) : null, + createElement("td", level.toUpperCase()), + createElement("td", { attrs: { - 'data-title': msg.Mod + "data-title": msg.Mod } }, msg.Mod), - createElement('td', [ - createElement('span', { - class: 'log-message-text' + createElement("td", [ + createElement("span", { + class: "log-message-text" }, text), - msg.IsStartOfSection ? createElement('span', { - class: 'section-toggle-message' + msg.IsStartOfSection ? createElement("span", { + class: "section-toggle-message" }, [ - ' ', + " ", toggleMessage ]) : null ]) @@ -457,7 +457,7 @@ smapi.logParser = function (state, sectionUrl) { // init app app = new Vue({ - el: '#output', + el: "#output", data: state, computed: { anyModsHidden: function () { @@ -541,7 +541,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: { @@ -551,16 +551,16 @@ 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')) + if (params.has("PerPage")) try { - const perPage = parseInt(params.get('PerPage')); + const perPage = parseInt(params.get("PerPage")); if (!isNaN(perPage) && isFinite(perPage) && perPage > 0) state.perPage = perPage; } catch { /* ignore errors */ } - if (params.has('Page')) + if (params.has("Page")) try { - const page = parseInt(params.get('Page')); + const page = parseInt(params.get("Page")); if (!isNaN(page) && isFinite(page) && page > 0) this.page = page; } catch { /* ignore errors */ } @@ -640,8 +640,8 @@ smapi.logParser = function (state, sectionUrl) { // really care about. updateUrl: function () { const url = new URL(location); - url.searchParams.set('Page', state.page); - url.searchParams.set('PerPage', state.perPage); + url.searchParams.set("Page", state.page); + url.searchParams.set("PerPage", state.perPage); window.history.replaceState(null, document.title, url.toString()); }, @@ -651,16 +651,16 @@ smapi.logParser = function (state, sectionUrl) { // 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; + let text = this.filterText = document.querySelector("input[type=text]").value; if (!text || !text.length) { - this.filterText = ''; + 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' + state.useInsensitive ? "ig" : "g" ); } }, 250), -- cgit From ccf760452d64e1965c92c5cf8af399a5e80d5a3a Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 9 Apr 2022 13:08:38 -0400 Subject: pass data directly to script instead of serializing & deserializing it --- src/SMAPI.Web/Views/LogParser/Index.cshtml | 43 ++++++---- src/SMAPI.Web/wwwroot/Content/js/log-parser.js | 113 +++++++++++-------------- 2 files changed, 76 insertions(+), 80 deletions(-) (limited to 'src/SMAPI.Web/wwwroot/Content/js') diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 8f44b4a2..ff8aa003 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -44,13 +44,6 @@ - @if (!Model.ShowRaw) - { - - - - - } @@ -58,15 +51,33 @@ + + +