summaryrefslogtreecommitdiff
path: root/src/SMAPI.Web/wwwroot
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI.Web/wwwroot')
-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
-rw-r--r--src/SMAPI.Web/wwwroot/schemas/content-patcher.json42
-rw-r--r--src/SMAPI.Web/wwwroot/schemas/manifest.json2
6 files changed, 1063 insertions, 57 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({
diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
index 6b80f260..4975a973 100644
--- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
+++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
@@ -14,9 +14,9 @@
"title": "Format version",
"description": "The format version. You should always use the latest version to enable the latest features, avoid obsolete behavior, and reduce load times.",
"type": "string",
- "const": "1.24.0",
+ "const": "1.25.0",
"@errorMessages": {
- "const": "Incorrect value '@value'. You should always use the latest format version (currently 1.24.0) to enable the latest features, avoid obsolete behavior, and reduce load times."
+ "const": "Incorrect value '@value'. You should always use the latest format version (currently 1.25.0) to enable the latest features, avoid obsolete behavior, and reduce load times."
}
},
"ConfigSchema": {
@@ -159,6 +159,14 @@
"additionalProperties": false
}
},
+ "AliasTokenNames": {
+ "title": "Alias token names",
+ "description": "Defines optional alternate name for existing tokens. This only affects your content pack, and you can use both the alias name and the original token name. This is mostly useful for custom tokens provided by other mods, which often have longer names. Each entry key is the alias name, and the value is the original token name.",
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
"Changes": {
"title": "Changes",
"description": "The changes you want to make. Each entry is called a patch, and describes a specific action to perform: replace this file, copy this image into the file, etc. You can list any number of patches.",
@@ -187,22 +195,6 @@
"description": "A name for this patch shown in log messages. This is very useful for understanding errors; if not specified, will default to a name like 'entry #14 (EditImage Animals/Dinosaurs)'.",
"type": "string"
},
- "Enabled": {
- "title": "Enabled",
- "description": "Whether to apply this patch. Default true. This field does not allow tokens.",
- "anyOf": [
- {
- "type": "boolean"
- },
- {
- "type": "string",
- "enum": [ "true", "false" ]
- }
- ],
- "@errorMessages": {
- "anyOf": "Invalid value; must be true, false, or a single token which evaluates to true or false."
- }
- },
"Update": {
"title": "Update",
"description": "When the patch should update if it changed. The possible values are 'OnDayStart', 'OnLocationChange', or 'OnTimeChange' (defaults to OnDayStart).",
@@ -241,6 +233,14 @@
"description": "The part of the target image to replace. Defaults to the FromArea size starting from the top-left corner.",
"$ref": "#/definitions/Rectangle"
},
+ "TargetField": {
+ "title": "Target field",
+ "description": "The path to the field within the value to set as the root scope. See 'target field' in the EditData documentation for more info. This field supports tokens.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"Fields": {
"title": "Fields",
"description": "The individual fields you want to change for existing entries. This field supports tokens in field keys and values. The key for each field is the field index (starting at zero) for a slash-delimited string, or the field name for an object.",
@@ -408,7 +408,6 @@
"propertyNames": {
"enum": [
"Action",
- "Enabled",
"FromFile",
"LogName",
"Target",
@@ -438,7 +437,6 @@
"propertyNames": {
"enum": [
"Action",
- "Enabled",
"FromFile",
"LogName",
"Target",
@@ -462,12 +460,12 @@
"propertyNames": {
"enum": [
"Action",
- "Enabled",
"LogName",
"Target",
"Update",
"When",
+ "TargetField",
"Entries",
"Fields",
"MoveEntries",
@@ -504,7 +502,6 @@
"propertyNames": {
"enum": [
"Action",
- "Enabled",
"FromFile",
"LogName",
"Target",
@@ -533,7 +530,6 @@
"propertyNames": {
"enum": [
"Action",
- "Enabled",
"FromFile",
"LogName",
"Update",
diff --git a/src/SMAPI.Web/wwwroot/schemas/manifest.json b/src/SMAPI.Web/wwwroot/schemas/manifest.json
index b6722347..7457b993 100644
--- a/src/SMAPI.Web/wwwroot/schemas/manifest.json
+++ b/src/SMAPI.Web/wwwroot/schemas/manifest.json
@@ -103,7 +103,7 @@
"type": "array",
"items": {
"type": "string",
- "pattern": "^(?i)(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_\\-]+/[A-Za-z0-9_\\-]+|ModDrop:\\d+)(?: *@ *[a-zA-Z0-9_]+ *)$",
+ "pattern": "^(?i)(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_\\-]+/[A-Za-z0-9_\\-]+|ModDrop:\\d+)(?: *@ *[a-zA-Z0-9_]+ *)?$",
"@errorMessages": {
"pattern": "Invalid update key; see https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info."
}