diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2acd739a..d581dd30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -64,6 +64,16 @@ accepts both, but the store flags the old spelling as deprecated
- `/plugins/store/refresh` no longer claims a commit-metadata refresh it doesn't do.
- The plugin-config list repair code is in one place, `src/web_interface/config_arrays.py`.
+- Web UI:
+ - Cache tab errors no longer show up in the Logs tab.
+ - A tab that fails to load shows "Try again" instead of a skeleton that never goes away.
+ - Plugin Store search and registry errors appear as a notification, and the Plugin Manager stays on screen.
+ - The image schedule button works on uploaded images, and the editor stays open while you edit.
+ - A failed plugin toggle moves the switch back.
+ - Each save shows one notification; a failed Durations save says it failed.
+ - Stats the server can't read show `--`.
+ - New `window.LEDEscape` (`html`, `attr`, `jsStringAttr`) replaces about 30 copied escapers. `window.escapeHtml` and `window.escapeAttribute` remain as aliases for plugin pages.
+
- The web service (`ledmatrix-web`) logs through `src.logging_config` like the
display service, so `journalctl -p err -u ledmatrix-web` works. Successful
GET/HEAD/OPTIONS requests (the UI's polling) are logged at DEBUG instead of
diff --git a/test/js/dom/test_installed_dom.js b/test/js/dom/test_installed_dom.js
index 5fbee8c6..aa5f70ab 100644
--- a/test/js/dom/test_installed_dom.js
+++ b/test/js/dom/test_installed_dom.js
@@ -43,12 +43,7 @@ function get(path) {
window.debugLog = () => {};
window.PLUGIN_DEBUG = false;
window.installedPlugins = installed;
- window.escapeHtml = function (text) {
- if (!text) return '';
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- };
+ require('../led_escape').install(window);
// Load the helper as a real ', 'a'b']) {
+ const out = LEDEscape.jsStringAttr(v);
+ ok(`${JSON.stringify(v)}: no raw quote or bracket`, !/["'<>]/.test(out), out);
+ // eslint-disable-next-line no-eval
+ ok(`${JSON.stringify(v)}: decodes back to the same string`, eval(decode(out)) === v, out);
+ }
+ ok('null and undefined become the empty string',
+ LEDEscape.html(null) === '' && LEDEscape.html(undefined) === '' && LEDEscape.jsStringAttr(null) === '""');
+ ok('numbers are kept', LEDEscape.html(0) === '0', LEDEscape.html(0));
+}
+
+console.log('\n4c. no hand-rolled escaper outside app-early.js');
+{
+ const skip = new Set(['static/v3/js/app-early.js',
+ // documentation example, kept self-contained on purpose
+ 'static/v3/js/widgets/example-color-picker.js']);
+ const found = [];
+ const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).forEach(e => {
+ const p = path.join(dir, e.name);
+ const rel = path.relative(ROOT, p).split(path.sep).join('/');
+ if (e.isDirectory()) { if (e.name !== 'vendor') walk(p); return; }
+ if (!/\.(js|html)$/.test(e.name) || /\.min\.js$/.test(e.name) || skip.has(rel)) return;
+ // Writing the entity for a quote is what an escaper does; nothing else in
+ // the UI needs to.
+ let text = fs.readFileSync(p, 'utf8');
+ // In templates only the inline scripts count; Jinja's own |replace("'", "'")
+ // escaping of server-rendered values is not a JS escaper.
+ if (e.name.endsWith('.html')) text = (text.match(/
-
+
@@ -246,45 +301,6 @@
});
-
-
-
-
-
-
-
-
@@ -548,7 +564,7 @@