summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web/wwwroot/Content
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI.Web/wwwroot/Content')
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/file-upload.css2
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/log-parser.css120
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/main.css26
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/log-parser.js928
4 files changed, 1043 insertions, 33 deletions
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 {
diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
index 8c3acceb..1d457e35 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
@@ -113,7 +113,7 @@ table caption {
}
.table tr {
- background: #eee
+ background: #eee;
}
#mods span.notice {
@@ -148,18 +148,79 @@ table caption {
font-style: italic;
}
+.table .content-packs-collapsed {
+ opacity: 0.75;
+ font-size: 0.9em;
+ font-style: italic;
+}
+
#metadata td:first-child {
padding-right: 5px;
}
.table tr:nth-child(even) {
- background: #fff
+ background: #fff;
}
#filters {
margin: 1em 0 0 0;
- padding: 0;
+ padding: 0 0 0.5em;
+ display: flex;
+ justify-content: space-between;
+ width: calc(100vw - 16em);
+}
+
+#filters > div {
+ align-self: center;
+}
+
+#filters .toggles {
+ display: flex;
+}
+
+#filters .toggles > div:first-child {
font-weight: bold;
+ padding: 0.2em 1em 0 0;
+}
+
+#filters .filter-text {
+ margin-top: 0.5em;
+}
+
+#filters .filter-error {
+ color: #880000;
+}
+
+#filters .filter-error,
+#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 - 5em);
+ }
+
+ #filters {
+ display: block;
+ }
}
#filters span {
@@ -173,6 +234,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 +260,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 +346,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/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css
index dcc7a798..a0a407d8 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/main.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/main.css
@@ -97,6 +97,22 @@ a {
margin-left: 1em;
}
+/* quick navigation */
+
+#quickNav {
+ position: fixed;
+ bottom: 3em;
+ width: 12em;
+}
+
+@media (max-height: 400px) {
+ #quickNav {
+ position: unset;
+ width: auto;
+ }
+}
+
+
/* footer */
#footer {
margin: 1em;
@@ -111,11 +127,16 @@ a {
/* mobile fixes */
@media (min-width: 1020px) and (max-width: 1199px) {
+ #quickNav,
#sidebar {
width: 7em;
background: none;
}
+ #quickNav h4 {
+ width: unset;
+ }
+
#content-column {
left: 7em;
}
@@ -138,4 +159,9 @@ a {
top: inherit;
left: inherit;
}
+
+ #quickNav {
+ position: unset;
+ width: auto;
+ }
}
diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
index 90715375..fccd00be 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
@@ -1,56 +1,922 @@
-/* globals $ */
+/* globals $, Vue */
+/**
+ * The global SMAPI module.
+ */
var smapi = smapi || {};
+
+/**
+ * The Vue app for the current page.
+ * @type {Vue}
+ */
var app;
-smapi.logParser = function (data, sectionUrl) {
+
+// Use a scroll event to apply a sticky effect to the filters / pagination
+// bar. We can't just use "position: sticky" due to how the page is structured
+// but this works well enough.
+$(function () {
+ let sticking = false;
+
+ document.addEventListener("scroll", function () {
+ const filters = document.getElementById("filters");
+ const holder = document.getElementById("filterHolder");
+ if (!filters || !holder)
+ return;
+
+ const offset = holder.offsetTop;
+ const shouldStick = window.pageYOffset > offset;
+ if (shouldStick === sticking)
+ return;
+
+ sticking = shouldStick;
+ if (sticking) {
+ holder.style.marginBottom = `calc(1em + ${filters.offsetHeight}px)`;
+ filters.classList.add("sticky");
+ }
+ else {
+ filters.classList.remove("sticky");
+ holder.style.marginBottom = "";
+ }
+ });
+});
+
+/**
+ * Initialize a log parser view on the current page.
+ * @param {object} state The state options to use.
+ * @returns {void}
+ */
+smapi.logParser = function (state) {
+ if (!state)
+ state = {};
+
+ // internal helpers
+ const helpers = {
+ /**
+ * Get a handler which invokes the callback after a set delay, resetting the delay each time it's called.
+ * @param {(...*) => void} action The callback to invoke when the delay ends.
+ * @param {number} delay The number of milliseconds to delay the action after each call.
+ * @returns {() => void}
+ */
+ getDebouncedHandler(action, delay) {
+ let timeoutId = null;
+
+ return function () {
+ clearTimeout(timeoutId);
+
+ const args = arguments;
+ const self = this;
+
+ timeoutId = setTimeout(
+ function () {
+ action.apply(self, args);
+ },
+ delay
+ );
+ }
+ },
+
+ /**
+ * Escape regex special characters in the given string.
+ * @param {string} text
+ * @returns {string}
+ */
+ escapeRegex(text) {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ },
+
+ /**
+ * Format a number for the user's locale.
+ * @param {number} value The number to format.
+ * @returns {string}
+ */
+ formatNumber(value) {
+ const formatter = window.Intl && Intl.NumberFormat && new Intl.NumberFormat();
+ return formatter && formatter.format
+ ? formatter.format(value)
+ : `${value}`;
+ },
+
+ /**
+ * Try parsing the value as a base-10 integer.
+ * @param {string} value The value to parse.
+ * @param {number} defaultValue The value to return if parsing fails.
+ * @param {() => boolean} criteria An optional callback to check whether a parsed number is valid.
+ * @returns {number} The parsed number if it's valid, else the default value.
+ */
+ tryParseNumber(value, defaultValue, criteria = null) {
+ value = parseInt(value, 10);
+ return !isNaN(value) && isFinite(value) && (!criteria || criteria(value))
+ ? value
+ : defaultValue;
+ },
+
+ /**
+ * Get whether two objects are equivalent based on their top-level properties.
+ * @param {Object} left The first value to compare.
+ * @param {Object} right The second value to compare.
+ * @returns {Boolean}
+ */
+ shallowEquals(left, right) {
+ if (typeof left !== "object" || typeof right !== "object")
+ return left === right;
+
+ if (left == null || right == null)
+ return left == null && right == null;
+
+ if (Array.isArray(left) !== Array.isArray(right))
+ return false;
+
+ const leftKeys = Object.keys(left);
+ const rightKeys = Object.keys(right);
+
+ if (leftKeys.length != rightKeys.length)
+ return false;
+
+ for (const key of leftKeys) {
+ if (!rightKeys.includes(key) || left[key] !== right[key])
+ return false;
+ }
+
+ return true;
+ }
+ };
+
+ // internal event handlers
+ const handlers = {
+ /**
+ * Method called when the user clicks a log line to toggle the visibility of a section. Binding methods is problematic with functional components so we just use the `data-section` parameter and our global reference to the app.
+ * @param {any} event
+ * @returns {false}
+ */
+ clickLogLine(event) {
+ app.toggleSection(event.currentTarget.dataset.section);
+ event.preventDefault();
+ return false;
+ },
+
+ /**
+ * Navigate to the previous page of messages in the log.
+ * @returns {void}
+ */
+ prevPage() {
+ app.prevPage();
+ },
+
+ /**
+ * Navigate to the next page of messages in the log.
+ * @returns {void}
+ */
+ nextPage() {
+ app.nextPage();
+ },
+
+ /**
+ * Handle a click on a page number element.
+ * @param {number | Event} event
+ * @returns {void}
+ */
+ changePage(event) {
+ if (typeof event === "number")
+ app.changePage(event);
+ else if (event) {
+ const page = parseInt(event.currentTarget.dataset.page);
+ if (!isNaN(page) && isFinite(page))
+ app.changePage(page);
+ }
+ }
+ };
+
// internal filter counts
- var stats = data.stats = {
+ const stats = state.stats = {
modsShown: 0,
modsHidden: 0
};
- function updateModFilters() {
- // counts
- stats.modsShown = 0;
- stats.modsHidden = 0;
- for (var key in data.showMods) {
- if (data.showMods.hasOwnProperty(key)) {
- if (data.showMods[key])
- stats.modsShown++;
- else
- stats.modsHidden++;
+
+ // load raw log data
+ {
+ const dataElement = document.querySelector(state.dataElement);
+ state.data = JSON.parse(dataElement.textContent.trim());
+ dataElement.remove(); // let browser unload the data element since we won't need it anymore
+ }
+
+ // preprocess data for display
+ state.messages = state.data.messages || [];
+ if (state.messages.length) {
+ const levels = state.data.logLevels;
+ const sections = state.data.sections;
+ const modSlugs = state.data.modSlugs;
+
+ for (let i = 0, length = state.messages.length; i < length; i++) {
+ const message = state.messages[i];
+
+ // add unique ID
+ message.id = i;
+
+ // add display values
+ message.LevelName = levels[message.Level];
+ message.SectionName = sections[message.Section];
+ message.ModSlug = modSlugs[message.Mod] || message.Mod;
+
+ // For repeated messages, since our <log-line /> component
+ // can't return two rows, just insert a second message
+ // which will display as the message repeated notice.
+ if (message.Repeated > 0 && !message.isRepeated) {
+ const repeatNote = {
+ id: i + 1,
+ Level: message.Level,
+ Section: message.Section,
+ Mod: message.Mod,
+ Repeated: message.Repeated,
+ isRepeated: true
+ };
+
+ state.messages.splice(i + 1, 0, repeatNote);
+ length++;
}
+
+ // let Vue know the message won't change, so it doesn't need to monitor it
+ Object.freeze(message);
}
}
+ Object.freeze(state.messages);
// set local time started
- if (data)
- data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2);
+ if (state.logStarted)
+ state.localTimeStarted = ("0" + state.logStarted.getHours()).slice(-2) + ":" + ("0" + state.logStarted.getMinutes()).slice(-2);
+
+ // add the properties we're passing to Vue
+ const defaultPerPage = 1000;
+ state.totalMessages = state.messages.length;
+ state.filterText = "";
+ state.filterRegex = null;
+ state.filterError = null;
+ state.showContentPacks = true;
+ state.useHighlight = true;
+ state.useRegex = false;
+ state.useInsensitive = true;
+ state.useWord = false;
+ state.perPage = defaultPerPage;
+ state.page = 1;
+
+ state.defaultMods = { ...state.showMods };
+ state.defaultSections = { ...state.showSections };
+ state.defaultLevels = { ...state.showLevels };
+
+ // load saved values, if any
+ if (localStorage.settings) {
+ try {
+ const saved = JSON.parse(localStorage.settings);
+
+ state.showContentPacks = saved.showContentPacks ?? state.showContentPacks;
+ state.useHighlight = saved.useHighlight ?? state.useHighlight;
+ state.useRegex = saved.useRegex ?? state.useRegex;
+ state.useInsensitive = saved.useInsensitive ?? state.useInsensitive;
+ state.useWord = saved.useWord ?? state.useWord;
+ }
+ catch (error) {
+ // ignore settings if invalid
+ }
+ }
+
+ // add a number formatter so our numbers look nicer
+ Vue.filter("number", handlers.formatNumber);
+
+ // Strictly speaking, we don't need this. However, due to the way our
+ // Vue template is living in-page the browser is "helpful" and moves
+ // our <log-line />s outside of a basic <table> since obviously they
+ // aren't table rows and don't belong inside a table. By using another
+ // Vue component, we avoid that.
+ Vue.component("log-table", {
+ functional: true,
+ render: function (createElement, context) {
+ return createElement(
+ "table",
+ {
+ attrs: {
+ id: "log"
+ }
+ },
+ context.children
+ );
+ }
+ });
+
+ // The <filter-stats /> component draws a nice message under the filters
+ // telling a user how many messages match their filters, and also expands
+ // on how many of them they're seeing because of pagination.
+ Vue.component("filter-stats", {
+ functional: true,
+ render: function (createElement, context) {
+ const props = context.props;
+ return createElement(
+ "div",
+ { class: "stats" },
+ [
+ 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)),
+ ")"
+ ]
+ );
+ }
+ });
+
+ // Next up we have <pager /> which renders the pagination list. This has a
+ // helper method to make building the list of links easier.
+ function addPageLink(page, links, visited, createElement, currentPage) {
+ if (visited.has(page))
+ return;
+
+ if (page > 1 && !visited.has(page - 1))
+ links.push(" … ");
+
+ visited.add(page);
+ links.push(createElement(
+ "span",
+ {
+ class: page === currentPage ? "active" : null,
+ attrs: {
+ "data-page": page
+ },
+ on: {
+ click: handlers.changePage
+ }
+ },
+ helpers.formatNumber(page)
+ ));
+ }
+
+ Vue.component("pager", {
+ functional: true,
+ render: function (createElement, context) {
+ const props = context.props;
+ if (props.pages <= 1)
+ return null;
+
+ const visited = new Set();
+ const pageLinks = [];
+
+ for (let i = 1; i <= 2; i++)
+ addPageLink(i, pageLinks, visited, createElement, props.page);
+
+ for (let i = props.page - 2; i <= props.page + 2; i++) {
+ if (i >= 1 && i <= props.pages)
+ addPageLink(i, pageLinks, visited, createElement, props.page);
+ }
+
+ for (let i = props.pages - 2; i <= props.pages; i++) {
+ if (i >= 1)
+ addPageLink(i, pageLinks, visited, createElement, props.page);
+ }
+
+ return createElement(
+ "div",
+ { class: "pager" },
+ [
+ createElement(
+ "span",
+ {
+ class: props.page <= 1 ? "disabled" : null,
+ on: {
+ click: handlers.prevPage
+ }
+ },
+ "Prev"
+ ),
+ " ",
+ "Page ",
+ helpers.formatNumber(props.page),
+ " of ",
+ helpers.formatNumber(props.pages),
+ " ",
+ createElement(
+ "span",
+ {
+ class: props.page >= props.pages ? "disabled" : null,
+ on: {
+ click: handlers.nextPage
+ }
+ },
+ "Next"
+ ),
+ createElement("div", {}, pageLinks)
+ ]
+ );
+ }
+ });
+
+ // Our <log-line /> functional component draws each log line.
+ Vue.component("log-line", {
+ functional: true,
+ props: {
+ showScreenId: {
+ type: Boolean,
+ required: true
+ },
+ message: {
+ type: Object,
+ required: true
+ },
+ highlight: {
+ type: Boolean,
+ required: false
+ }
+ },
+ render: function (createElement, context) {
+ const message = context.props.message;
+ const level = message.LevelName;
+
+ if (message.isRepeated)
+ return createElement(
+ "tr",
+ {
+ class: [
+ "mod",
+ level,
+ "mod-repeat"
+ ]
+ },
+ [
+ createElement(
+ "td",
+ {
+ attrs: {
+ colspan: context.props.showScreenId ? 4 : 3
+ }
+ },
+ ""
+ ),
+ createElement("td", `repeats ${message.Repeated} times`)
+ ]
+ );
+
+ const events = {};
+ let toggleMessage;
+ if (message.IsStartOfSection) {
+ const visible = message.SectionName && window.app && app.sectionsAllow(message.SectionName);
+ events.click = handlers.clickLogLine;
+ toggleMessage = visible
+ ? "This section is shown. Click here to hide it."
+ : "This section is hidden. Click here to show it.";
+ }
+
+ let text = message.Text;
+ const filter = window.app && app.filterRegex;
+ if (text && filter && context.props.highlight) {
+ text = [];
+ let match;
+ let consumed = 0;
+ let index = 0;
+ filter.lastIndex = -1;
+
+ // Our logic to highlight the text is a bit funky because we
+ // want to group consecutive matches to avoid a situation
+ // where a ton of single characters are in their own elements
+ // if the user gives us bad input.
+
+ while (true) {
+ match = filter.exec(message.Text);
+ if (!match)
+ break;
+
+ // Do we have an area of non-matching text? This
+ // happens if the new match's index is further
+ // along than the last index.
+ if (match.index > index) {
+ // Alright, do we have a previous match? If
+ // we do, we need to consume some text.
+ if (consumed < index)
+ text.push(createElement("strong", {}, message.Text.slice(consumed, index)));
+
+ text.push(message.Text.slice(index, match.index));
+ consumed = match.index;
+ }
+
+ index = match.index + match[0].length;
+
+ // In the event of a zero-length match, forcibly increment
+ // the last index of the regular expression to ensure we
+ // aren't stuck in an infinite loop.
+ if (match[0].length == 0)
+ filter.lastIndex++;
+ }
+
+ // Add any trailing text after the last match was found.
+ if (consumed < message.Text.length) {
+ if (consumed < index)
+ text.push(createElement("strong", {}, message.Text.slice(consumed, index)));
+
+ if (index < message.Text.length)
+ text.push(message.Text.slice(index));
+ }
+ }
+
+ return createElement(
+ "tr",
+ {
+ class: [
+ "mod",
+ level,
+ message.IsStartOfSection ? "section-start" : null
+ ],
+ attrs: {
+ "data-section": message.SectionName
+ },
+ on: events
+ },
+ [
+ createElement("td", message.Time),
+ context.props.showScreenId ? createElement("td", message.ScreenId) : null,
+ createElement("td", level.toUpperCase()),
+ createElement(
+ "td",
+ {
+ attrs: {
+ "data-title": message.Mod
+ }
+ },
+ message.Mod
+ ),
+ createElement(
+ "td",
+ [
+ createElement(
+ "span",
+ { class: "log-message-text" },
+ text
+ ),
+ message.IsStartOfSection
+ ? createElement(
+ "span",
+ { class: "section-toggle-message" },
+ [
+ " ",
+ toggleMessage
+ ]
+ )
+ : null
+ ]
+ )
+ ]
+ );
+ }
+ });
// init app
app = new Vue({
- el: '#output',
- data: data,
+ el: "#output",
+ data: state,
computed: {
anyModsHidden: function () {
return stats.modsHidden > 0;
},
anyModsShown: function () {
return stats.modsShown > 0;
+ },
+ showScreenId: function () {
+ return this.data.screenIds.length > 1;
+ },
+
+ // Maybe not strictly necessary, but the Vue template is being
+ // weird about accessing data entries on the app rather than
+ // computed properties.
+ hideContentPacks: function () {
+ return !state.showContentPacks;
+ },
+
+ // Filter messages for visibility.
+ filterUseRegex: function () {
+ return state.useRegex;
+ },
+ filterInsensitive: function () {
+ return state.useInsensitive;
+ },
+ filterUseWord: function () {
+ return state.useWord;
+ },
+ shouldHighlight: function () {
+ return state.useHighlight;
+ },
+
+ filteredMessages: function () {
+ if (!state.messages)
+ return [];
+
+ //const start = performance.now();
+ const filtered = [];
+
+ let total = 0;
+
+ // This is slightly faster than messages.filter(), which is
+ // important when working with absolutely huge logs.
+ for (let i = 0, length = state.messages.length; i < length; i++) {
+ const msg = state.messages[i];
+ if (!this.filtersAllow(msg.ModSlug, msg.LevelName))
+ continue;
+
+ if (this.filterRegex) {
+ const text = msg.Text || (i > 0 ? state.messages[i - 1].Text : null);
+ this.filterRegex.lastIndex = -1;
+ if (!text || !this.filterRegex.test(text))
+ continue;
+ }
+
+ total++;
+
+ if (msg.SectionName && !msg.IsStartOfSection && !this.sectionsAllow(msg.SectionName))
+ continue;
+
+ filtered.push(msg);
+ }
+
+ filtered.total = total;
+
+ Object.freeze(filtered);
+
+ //const end = performance.now();
+ //console.log(`applied ${(this.useRegex ? "regex" : "text")} filter '${this.filterRegex}' in ${end - start}ms`);
+
+ return filtered;
+ },
+
+ // And the rest are about pagination.
+ start: function () {
+ return (this.page - 1) * state.perPage;
+ },
+ end: function () {
+ return this.start + this.visibleMessages.length;
+ },
+ totalPages: function () {
+ return Math.ceil(this.filteredMessages.length / state.perPage);
+ },
+ //
+ visibleMessages: function () {
+ if (this.totalPages <= 1)
+ return this.filteredMessages;
+
+ const start = this.start;
+ const end = start + state.perPage;
+
+ return this.filteredMessages.slice(start, end);
}
},
+ created: function () {
+ window.addEventListener("popstate", () => this.loadFromUrl());
+ this.loadFromUrl();
+ },
methods: {
+ loadFromUrl: function () {
+ const params = new URL(location).searchParams;
+
+ state.perPage = helpers.tryParseNumber(params.get("PerPage"), defaultPerPage, n => n > 0);
+ this.page = helpers.tryParseNumber(params.get("Page"), 1, n => n > 0);
+ state.filterText = params.get("Filter") || "";
+
+ if (params.has("FilterMode")) {
+ const values = params.get("FilterMode").split("~");
+ state.useRegex = values.includes("Regex");
+ state.useInsensitive = !values.includes("Sensitive");
+ state.useWord = values.includes("Word");
+ }
+ else {
+ state.useRegex = false;
+ state.useInsensitive = true;
+ state.useWord = false;
+ }
+
+ if (params.has("Mods")) {
+ const value = params.get("Mods").split("~");
+ for (const key of Object.keys(this.showMods))
+ this.showMods[key] = value.includes(key);
+
+ }
+ else {
+ for (const key of Object.keys(this.showMods))
+ this.showMods[key] = state.defaultMods[key];
+ }
+
+ if (params.has("Levels")) {
+ const values = params.get("Levels").split("~");
+ for (const key of Object.keys(this.showLevels))
+ this.showLevels[key] = values.includes(key);
+
+ }
+ else {
+ const keys = Object.keys(this.showLevels);
+ for (const key of Object.keys(this.showLevels))
+ this.showLevels[key] = state.defaultLevels[key];
+ }
+
+ if (params.has("Sections")) {
+ const values = params.get("Sections").split("~");
+ for (const key of Object.keys(this.showSections))
+ this.showSections[key] = values.includes(key);
+
+ }
+ else {
+ for (const key of Object.keys(this.showSections))
+ this.showSections[key] = state.defaultSections[key];
+ }
+
+ this.updateModFilters();
+ this.updateFilterText();
+ },
+
+ /**
+ * Update the page URL to track non-default filter values.
+ */
+ updateUrl: function () {
+ const url = new URL(location);
+
+ if (state.page != 1 || state.perPage != defaultPerPage) {
+ url.searchParams.set("Page", state.page);
+ url.searchParams.set("PerPage", state.perPage);
+ }
+ else {
+ url.searchParams.delete("Page");
+ url.searchParams.delete("PerPage");
+ }
+
+ if (!helpers.shallowEquals(this.showMods, state.defaultMods))
+ url.searchParams.set("Mods", Object.entries(this.showMods).filter(p => p[1]).map(p => p[0]).join("~"));
+ else
+ url.searchParams.delete("Mods");
+
+ if (!helpers.shallowEquals(this.showLevels, state.defaultLevels))
+ url.searchParams.set("Levels", Object.entries(this.showLevels).filter(p => p[1]).map(p => p[0]).join("~"));
+ else
+ url.searchParams.delete("Levels");
+
+ if (!helpers.shallowEquals(this.showSections, state.defaultSections))
+ url.searchParams.set("Sections", Object.entries(this.showSections).filter(p => p[1]).map(p => p[0]).join("~"));
+ else
+ url.searchParams.delete("Sections");
+
+ if (state.filterText?.length) {
+ url.searchParams.set("Filter", state.filterText);
+
+ const modes = [];
+ if (state.useRegex)
+ modes.push("Regex");
+ if (!state.useInsensitive)
+ modes.push("Sensitive");
+ if (state.useWord)
+ modes.push("Word");
+
+ if (modes.length)
+ url.searchParams.set("FilterMode", modes.join("~"));
+ else
+ url.searchParams.delete("FilterMode");
+
+ }
+ else {
+ url.searchParams.delete("Filter");
+ url.searchParams.delete("FilterMode");
+ }
+
+ window.history.replaceState(null, document.title, url.toString()); // use replaceState instead of pushState to avoid filling the tab history with history steps the user probably doesn't care about
+ },
+
toggleLevel: function (id) {
- if (!data.enableFilters)
+ if (!state.enableFilters)
return;
this.showLevels[id] = !this.showLevels[id];
+ this.updateUrl();
+ },
+
+ toggleContentPacks: function () {
+ state.showContentPacks = !state.showContentPacks;
+ this.saveSettings();
+ },
+
+ toggleFilterUseRegex: function () {
+ state.useRegex = !state.useRegex;
+ this.saveSettings();
+ this.updateFilterText();
+ },
+
+ toggleFilterInsensitive: function () {
+ state.useInsensitive = !state.useInsensitive;
+ this.saveSettings();
+ this.updateFilterText();
+ },
+
+ toggleFilterWord: function () {
+ state.useWord = !state.useWord;
+ this.saveSettings();
+ this.updateFilterText();
+ },
+
+ toggleHighlight: function () {
+ state.useHighlight = !state.useHighlight;
+ this.saveSettings();
+ },
+
+ prevPage: function () {
+ if (this.page <= 1)
+ return;
+ this.page--;
+ this.updateUrl();
+ },
+
+ nextPage: function () {
+ if (this.page >= this.totalPages)
+ return;
+ this.page++;
+ this.updateUrl();
+ },
+
+ changePage: function (page) {
+ if (page < 1 || page > this.totalPages)
+ return;
+ this.page = page;
+ this.updateUrl();
+ },
+
+ /**
+ * Persist settings into localStorage for use the next time the user opens a log.
+ */
+ saveSettings: function () {
+ localStorage.settings = JSON.stringify({
+ showContentPacks: state.showContentPacks,
+ useRegex: state.useRegex,
+ useInsensitive: state.useInsensitive,
+ useWord: state.useWord,
+ useHighlight: state.useHighlight
+ });
+ },
+
+ // We don't want to update the filter text often, so use a debounce with
+ // a quarter second delay. We basically always build a regular expression
+ // since we use it for highlighting, and it also make case insensitivity
+ // much easier.
+ updateFilterText: helpers.getDebouncedHandler(
+ function () {
+ // reset
+ this.filterError = null;
+ this.filterRegex = null;
+
+ // apply search
+ let text = state.filterText;
+ if (!text)
+ this.filterText = "";
+ else {
+ if (!state.useRegex)
+ text = helpers.escapeRegex(text);
+
+ const flags = state.useInsensitive ? "ig" : "g";
+
+ try {
+ this.filterRegex = new RegExp(text, flags);
+ }
+ catch (err) {
+ this.filterError = err.message;
+ }
+
+ if (this.filterRegex && state.useWord)
+ this.filterRegex = new RegExp(`\\b(?:${text})\\b`, flags);
+ }
+
+ this.updateUrl();
+ },
+ 250
+ ),
+
+ updateModFilters: function () {
+ // counts
+ stats.modsShown = 0;
+ stats.modsHidden = 0;
+ for (let key in state.showMods) {
+ if (state.showMods.hasOwnProperty(key)) {
+ if (state.showMods[key])
+ stats.modsShown++;
+ else
+ stats.modsHidden++;
+ }
+ }
},
toggleMod: function (id) {
- if (!data.enableFilters)
+ if (!state.enableFilters)
return;
- var curShown = this.showMods[id];
+ const curShown = this.showMods[id];
// first filter: only show this by default
if (stats.modsHidden === 0) {
@@ -66,38 +932,42 @@ smapi.logParser = function (data, sectionUrl) {
else
this.showMods[id] = !this.showMods[id];
- updateModFilters();
+ this.updateModFilters();
+ this.updateUrl();
},
toggleSection: function (name) {
- if (!data.enableFilters)
+ if (!state.enableFilters)
return;
this.showSections[name] = !this.showSections[name];
+ this.updateUrl();
},
showAllMods: function () {
- if (!data.enableFilters)
+ if (!state.enableFilters)
return;
- for (var key in this.showMods) {
+ for (let key in this.showMods) {
if (this.showMods.hasOwnProperty(key)) {
this.showMods[key] = true;
}
}
- updateModFilters();
+ this.updateModFilters();
+ this.updateUrl();
},
hideAllMods: function () {
- if (!data.enableFilters)
+ if (!state.enableFilters)
return;
- for (var key in this.showMods) {
+ for (let key in this.showMods) {
if (this.showMods.hasOwnProperty(key)) {
this.showMods[key] = false;
}
}
- updateModFilters();
+ this.updateModFilters();
+ this.updateUrl();
},
filtersAllow: function (modId, level) {
@@ -113,7 +983,7 @@ smapi.logParser = function (data, sectionUrl) {
/**********
** Upload form
*********/
- var input = $("#input");
+ const input = $("#input");
if (input.length) {
// file upload
smapi.fileUpload({