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