Export, Import and Reset; JSON editor removed
- Export downloads one file with the saved config, all match data and history, the flight cache and view settings - Import restores such a file after a confirmation; older config-only .json files are accepted and keep existing match data - Reset (with a warning) deletes everything and starts over with new random example watches; replaces "Reset state" - The JSON config section is gone - Old ntfy targets no longer stop the script: they are ignored at runtime and on import, shown as "no longer supported" in the editor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
06c41615b8
commit
9caff094ac
3 files changed
+135
-64
No files matched your search
@@ -58,15 +58,21 @@ In the **Config** section of the dashboard:
|
|||||||
3. Press **Save & restart**, then **Test notification**.
|
3. Press **Save & restart**, then **Test notification**.
|
||||||
4. Optional: **Baseline (no alerts)** records what is available right now, so you are only told about new seats.
|
4. Optional: **Baseline (no alerts)** records what is available right now, so you are only told about new seats.
|
||||||
|
|
||||||
### JSON export / import / reset
|
|
||||||
|
|
||||||
Under the tables, **JSON – export / import / reset** holds the same config as JSON:
|
|
||||||
*Download .json* for a backup, *Load file…* or paste + *Apply JSON to tables* to restore,
|
|
||||||
*New random example* to replace the watches with freshly generated ones (notification targets are kept). Nothing takes effect until you press **Save & restart**.
|
|
||||||
|
|
||||||
Unsaved edits are colour-coded until you save or discard: **yellow** = changed field, **green** = new row,
|
Unsaved edits are colour-coded until you save or discard: **yellow** = changed field, **green** = new row,
|
||||||
**red** = row that will be removed on save (click ↺ to keep it).
|
**red** = row that will be removed on save (click ↺ to keep it).
|
||||||
|
|
||||||
|
### Export, Import, Reset
|
||||||
|
|
||||||
|
The buttons at the top right:
|
||||||
|
|
||||||
|
- **Export** downloads one `.json` file with everything: the saved config, all tracked matches (including drops and
|
||||||
|
sold-out flights), recent changes, the flight cache, and view settings (collapsed watches, sort order).
|
||||||
|
Unsaved edits in the config tables are not included.
|
||||||
|
- **Import** restores such a file after a confirmation and replaces the current config and data. Config-only `.json`
|
||||||
|
files from older versions are accepted too; they replace just the config and keep your matches and history.
|
||||||
|
- **Reset** deletes everything, after a warning, and starts over with new random example watches. Export first if you
|
||||||
|
want a backup.
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
- **Current matches** per watch, sorted *By departure* or *Newest first* (most recently appeared seats on top).
|
- **Current matches** per watch, sorted *By departure* or *Newest first* (most recently appeared seats on top).
|
||||||
@@ -100,15 +106,15 @@ Only one tab polls at a time, so opening the dashboard twice does not double the
|
|||||||
|
|
||||||
- Runs only while the browser is open and the computer awake.
|
- Runs only while the browser is open and the computer awake.
|
||||||
- Firefox / Chrome may unload background tabs under memory pressure; pinning the tab usually prevents it.
|
- Firefox / Chrome may unload background tabs under memory pressure; pinning the tab usually prevents it.
|
||||||
- Config, matches and history live in the userscript manager's storage on that browser only. Use *Download .json*
|
- Config, matches and history live in the userscript manager's storage on that browser only. Use **Export** /
|
||||||
to move the config elsewhere.
|
**Import** to back up or move everything to another browser.
|
||||||
|
|
||||||
## Auto-update
|
## Auto-update
|
||||||
|
|
||||||
The script header points Tampermonkey at this repo:
|
The script header points Tampermonkey at this repo:
|
||||||
|
|
||||||
```
|
```
|
||||||
// @version 1.4.0
|
// @version 1.5.0
|
||||||
// @updateURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js
|
// @updateURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js
|
||||||
// @downloadURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js
|
// @downloadURL https://gitea.mscloud.site/mans/sas-bonus-watch/raw/branch/main/sas-bonus-watch.user.js
|
||||||
```
|
```
|
||||||
@@ -131,7 +137,7 @@ Requirements:
|
|||||||
|
|
||||||
1. Edit `sas-bonus-watch.user.js`.
|
1. Edit `sas-bonus-watch.user.js`.
|
||||||
2. Run the tests: `node --test` (Node 20+).
|
2. Run the tests: `node --test` (Node 20+).
|
||||||
3. **Bump `@version`** (e.g. 1.4.0 → 1.4.1). Without this, nobody gets the update.
|
3. **Bump `@version`** (e.g. 1.5.0 → 1.5.1). Without this, nobody gets the update.
|
||||||
4. Commit and push to `main`.
|
4. Commit and push to `main`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -270,3 +270,23 @@ test('ntfy targets are rejected with a clear message; zulip is accepted', () =>
|
|||||||
assert.equal(core.validateConfig({ notify: [{ type: 'zulip', url: 'https://x.zulipchat.com/api/v1/external/slack_incoming?api_key=k&stream=s' }],
|
assert.equal(core.validateConfig({ notify: [{ type: 'zulip', url: 'https://x.zulipchat.com/api/v1/external/slack_incoming?api_key=k&stream=s' }],
|
||||||
watches: [watch()] }).notify[0].type, 'zulip');
|
watches: [watch()] }).notify[0].type, 'zulip');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('import reads full exports and older config-only backups, and rejects junk', () => {
|
||||||
|
const config = { notify: [], watches: [watch()] };
|
||||||
|
const state = { matches: { tokyo: { a: {}, b: {} } }, events: [] };
|
||||||
|
const full = core.parseBackup(JSON.stringify({ app: 'sas-bonus-watch', format: 1, exported_at: '2026-10-13T08:00:00Z',
|
||||||
|
data: { config, state, collapsed: { tokyo: true }, matchSort: 'newest' } }));
|
||||||
|
assert.equal(full.full, true);
|
||||||
|
assert.equal(full.tracked, 2);
|
||||||
|
assert.deepEqual(full.state, state);
|
||||||
|
assert.deepEqual(full.prefs, { collapsed: { tokyo: true }, matchSort: 'newest' });
|
||||||
|
|
||||||
|
const old = core.parseBackup(JSON.stringify(config));
|
||||||
|
assert.deepEqual([old.full, old.config], [false, config]);
|
||||||
|
|
||||||
|
assert.throws(() => core.parseBackup('{oops'), /not valid JSON/);
|
||||||
|
assert.throws(() => core.parseBackup('{"hello": 1}'), /not a SAS bonus watch export/);
|
||||||
|
assert.throws(() => core.parseBackup(JSON.stringify({ app: 'sas-bonus-watch', data: { state } })), /no config/);
|
||||||
|
assert.throws(() => core.parseBackup(JSON.stringify({ app: 'sas-bonus-watch', data: { config: { watches: [{ origins: 'X' }] } } })),
|
||||||
|
/not a 3-letter airport code/);
|
||||||
|
});
|
||||||
+99
-54
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name SAS bonus watch
|
// @name SAS bonus watch
|
||||||
// @namespace sas-bonus-finder
|
// @namespace sas-bonus-finder
|
||||||
// @version 1.4.0
|
// @version 1.5.0
|
||||||
// @description Watches SAS EuroBonus award seats from your own browser and alerts you when matching seats appear.
|
// @description Watches SAS EuroBonus award seats from your own browser and alerts you when matching seats appear.
|
||||||
// @author Måns Sivertsson
|
// @author Måns Sivertsson
|
||||||
// @homepageURL https://gitea.mscloud.site/mans/sas-bonus-watch
|
// @homepageURL https://gitea.mscloud.site/mans/sas-bonus-watch
|
||||||
@@ -536,7 +536,31 @@
|
|||||||
return { runCycle, estimate };
|
return { runCycle, estimate };
|
||||||
}
|
}
|
||||||
|
|
||||||
const core = { CABINS, THEMES, PLACES, exampleConfig, exampleWatch, easterSunday, validateConfig, migrateWatch, parseCalendar, parseFlight, createWatcher, legNeeds, applyRoundTrip, DEFAULT_CONFIG };
|
/**
|
||||||
|
* Reads an Export file ({app, data: {config, state, ...prefs}}) or, for older backups, a plain config.
|
||||||
|
* Throws with a readable message when the file can't be used.
|
||||||
|
*/
|
||||||
|
function parseBackup(text) {
|
||||||
|
// old ntfy targets are ignored at runtime (removed in 1.4.0), so they shouldn't block an import either
|
||||||
|
const check = (c) => validateConfig({ ...c, notify: (c.notify || []).filter((n) => String(n?.type).toLowerCase() !== 'ntfy') });
|
||||||
|
let d;
|
||||||
|
try { d = JSON.parse(text); } catch { throw new Error('the file is not valid JSON'); }
|
||||||
|
if (d && d.app === 'sas-bonus-watch' && d.data) {
|
||||||
|
const { config, state, ...prefs } = d.data;
|
||||||
|
if (!config) throw new Error('the file has no config');
|
||||||
|
check(config);
|
||||||
|
if (state != null && (typeof state !== 'object' || Array.isArray(state))) throw new Error('the file has invalid match data');
|
||||||
|
const tracked = Object.values(state?.matches || {}).reduce((n, m) => n + Object.keys(m || {}).length, 0);
|
||||||
|
return { full: true, config, state: state || {}, prefs, exportedAt: d.exported_at, tracked };
|
||||||
|
}
|
||||||
|
if (d && typeof d === 'object' && (d.watches || d.settings || d.notify)) {
|
||||||
|
check(d);
|
||||||
|
return { full: false, config: d };
|
||||||
|
}
|
||||||
|
throw new Error('this is not a SAS bonus watch export');
|
||||||
|
}
|
||||||
|
|
||||||
|
const core = { parseBackup, CABINS, THEMES, PLACES, exampleConfig, exampleWatch, easterSunday, validateConfig, migrateWatch, parseCalendar, parseFlight, createWatcher, legNeeds, applyRoundTrip, DEFAULT_CONFIG };
|
||||||
if (typeof module === 'object' && module.exports) { module.exports = core; return; } // node tests
|
if (typeof module === 'object' && module.exports) { module.exports = core; return; } // node tests
|
||||||
|
|
||||||
// ================================================================ browser runtime
|
// ================================================================ browser runtime
|
||||||
@@ -562,9 +586,18 @@
|
|||||||
rawConfig = exampleConfig();
|
rawConfig = exampleConfig();
|
||||||
gmSet('config', rawConfig);
|
gmSet('config', rawConfig);
|
||||||
}
|
}
|
||||||
try { cfg = validateConfig(rawConfig); } catch (e) { cfgError = e.message; }
|
// ntfy was removed in 1.4.0: ignore old ntfy targets at runtime instead of stopping, and ask to remove them
|
||||||
|
const isNtfy = (n) => String(n?.type).toLowerCase() === 'ntfy';
|
||||||
|
const legacyNtfy = (rawConfig.notify || []).filter(isNtfy).length;
|
||||||
|
const cfgNotice = legacyNtfy
|
||||||
|
? `${legacyNtfy} ntfy notification target(s) are ignored: ntfy is no longer supported. Remove the row and Save.` : '';
|
||||||
|
try {
|
||||||
|
cfg = validateConfig({ ...rawConfig, notify: (rawConfig.notify || []).filter((n) => !isNtfy(n)) });
|
||||||
|
} catch (e) { cfgError = e.message; }
|
||||||
const state = gmGet('state', {});
|
const state = gmGet('state', {});
|
||||||
const saveState = () => gmSet('state', state);
|
let frozen = false; // set while importing/resetting so a sweep in progress can't write old data back
|
||||||
|
const saveState = () => { if (!frozen) gmSet('state', state); };
|
||||||
|
const PREF_KEYS = ['collapsed', 'matchSort', 'paused'];
|
||||||
|
|
||||||
// -- notifications
|
// -- notifications
|
||||||
function gmPost(url, body, headers = {}) {
|
function gmPost(url, body, headers = {}) {
|
||||||
@@ -673,6 +706,7 @@
|
|||||||
async function mainLoop() {
|
async function mainLoop() {
|
||||||
if (cfgError) { status.phase = 'config error'; ui.render(); return; }
|
if (cfgError) { status.phase = 'config error'; ui.render(); return; }
|
||||||
if (!cfg.watches.length) { status.phase = 'no active watches'; ui.render(); return; }
|
if (!cfg.watches.length) { status.phase = 'no active watches'; ui.render(); return; }
|
||||||
|
if (cfgNotice) log(cfgNotice, 'warn');
|
||||||
const watcher = createWatcher({ cfg, api, state, save: saveState, notify, log });
|
const watcher = createWatcher({ cfg, api, state, save: saveState, notify, log });
|
||||||
log(`budget: ${watcher.estimate()}; ≥${cfg.settings.min_request_interval} s between requests, sweep every ${cfg.settings.cycle_interval} s`);
|
log(`budget: ${watcher.estimate()}; ≥${cfg.settings.min_request_interval} s between requests, sweep every ${cfg.settings.cycle_interval} s`);
|
||||||
setInterval(() => { if (status.phase !== 'other tab') holdLock(); }, 30000);
|
setInterval(() => { if (status.phase !== 'other tab') holdLock(); }, 30000);
|
||||||
@@ -734,6 +768,8 @@
|
|||||||
button { font: inherit; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--card); color: var(--fg); cursor: pointer; }
|
button { font: inherit; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--card); color: var(--fg); cursor: pointer; }
|
||||||
button.primary { background: var(--acc); border-color: var(--acc); color: #fff; }
|
button.primary { background: var(--acc); border-color: var(--acc); color: #fff; }
|
||||||
button.icon { padding: 2px 8px; color: var(--muted); }
|
button.icon { padding: 2px 8px; color: var(--muted); }
|
||||||
|
button.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, var(--line)); }
|
||||||
|
.btns .spacer { flex: 1; }
|
||||||
button:disabled { opacity: .5; cursor: default; }
|
button:disabled { opacity: .5; cursor: default; }
|
||||||
table { width:100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
table { width:100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
||||||
td, th { text-align:left; padding: 4px 6px 4px 3px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
td, th { text-align:left; padding: 4px 6px 4px 3px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||||||
@@ -890,17 +926,17 @@
|
|||||||
const effective = () => ({ ...draft, watches: draft.watches.filter((w) => !removed.has(w)),
|
const effective = () => ({ ...draft, watches: draft.watches.filter((w) => !removed.has(w)),
|
||||||
notify: draft.notify.filter((n) => !removed.has(n)) });
|
notify: draft.notify.filter((n) => !removed.has(n)) });
|
||||||
|
|
||||||
/** Start editing `fresh`; remember which saved row each draft row came from (by position, or by name after an import). */
|
/** Start editing `fresh`; remember which saved row each draft row came from. */
|
||||||
function loadDraft(fresh, byName = false) {
|
function loadDraft(fresh) {
|
||||||
draft = fresh;
|
draft = fresh;
|
||||||
removed = new Set();
|
removed = new Set();
|
||||||
origOf = new WeakMap([[draft.settings, saved.settings]]);
|
origOf = new WeakMap([[draft.settings, saved.settings]]);
|
||||||
draft.watches.forEach((w, i) => {
|
draft.watches.forEach((w, i) => {
|
||||||
const o = byName ? saved.watches.find((x) => x.name === w.name) : saved.watches[i];
|
const o = saved.watches[i];
|
||||||
if (o) origOf.set(w, o);
|
if (o) origOf.set(w, o);
|
||||||
});
|
});
|
||||||
draft.notify.forEach((n, i) => {
|
draft.notify.forEach((n, i) => {
|
||||||
const o = byName ? saved.notify.find((x) => x.url === n.url) : saved.notify[i];
|
const o = saved.notify[i];
|
||||||
if (o) origOf.set(n, o);
|
if (o) origOf.set(n, o);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -973,8 +1009,10 @@
|
|||||||
|
|
||||||
function notifyRow(n) {
|
function notifyRow(n) {
|
||||||
return h('tr', { class: rowClass(n) },
|
return h('tr', { class: rowClass(n) },
|
||||||
h('td', {}, h('select', { ...bind(n, 'type') }, NOTIFY_TYPES.map((t) =>
|
h('td', {}, h('select', { ...bind(n, 'type') },
|
||||||
h('option', { value: t, selected: n.type === t }, t)))),
|
(NOTIFY_TYPES.includes(n.type) ? NOTIFY_TYPES : [n.type, ...NOTIFY_TYPES]).map((t) =>
|
||||||
|
h('option', { value: t, selected: n.type === t, disabled: !NOTIFY_TYPES.includes(t) },
|
||||||
|
NOTIFY_TYPES.includes(t) ? t : `${t} (no longer supported)`)))),
|
||||||
h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'Webhook URL', ...bind(n, 'url') })),
|
h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'Webhook URL', ...bind(n, 'url') })),
|
||||||
h('td', {}, removeBtn(draft.notify, n, 'notification')));
|
h('td', {}, removeBtn(draft.notify, n, 'notification')));
|
||||||
}
|
}
|
||||||
@@ -1014,45 +1052,48 @@
|
|||||||
changed();
|
changed();
|
||||||
}
|
}
|
||||||
|
|
||||||
function jsonSection() {
|
// ---- export / import / reset -------------------------------------------------
|
||||||
const json = h('textarea', { spellcheck: 'false' });
|
function exportAll() {
|
||||||
const msg = h('div', { class: 'small' });
|
const data = { config: rawConfig, state };
|
||||||
const apply = () => {
|
for (const k of PREF_KEYS) { const v = gmGet(k, null); if (v !== null) data[k] = v; }
|
||||||
try {
|
const file = { app: 'sas-bonus-watch', format: 1, version: typeof GM_info === 'object' ? GM_info.script?.version : undefined,
|
||||||
const parsed = normalizeDraft(JSON.parse(json.value));
|
exported_at: new Date().toISOString(), data };
|
||||||
validateConfig(parsed);
|
const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' })),
|
||||||
loadDraft(parsed, true);
|
download: `sas-bonus-watch-${todayISO()}.json` });
|
||||||
renderEditor();
|
a.click();
|
||||||
msg.className = 'small ok';
|
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
msg.textContent = 'Loaded into the tables above. Press "Save & restart" to use it.';
|
log(dirty ? 'exported (saved config only; unsaved edits are not included)' : 'exported config and data');
|
||||||
} catch (e) { msg.className = 'small err'; msg.textContent = e.message; }
|
}
|
||||||
};
|
|
||||||
const file = h('input', { type: 'file', accept: '.json,application/json', style: 'display:none', onchange: async (e) => {
|
async function importFile(f) {
|
||||||
const f = e.target.files[0];
|
let b;
|
||||||
if (f) { json.value = await f.text(); apply(); }
|
try { b = parseBackup(await f.text()); } catch (e) { alert(`Import failed: ${e.message}`); return; }
|
||||||
e.target.value = '';
|
const what = b.full
|
||||||
} });
|
? `the config, ${b.tracked} tracked flight(s) and history from ${b.exportedAt ? new Date(b.exportedAt).toLocaleString('sv-SE') : 'the file'}`
|
||||||
return h('details', { ontoggle: (e) => { if (e.target.open) json.value = JSON.stringify(effective(), null, 2); } },
|
: 'the config only (your current matches and history are kept)';
|
||||||
h('summary', {}, 'JSON – export / import / reset'),
|
if (!confirm(`Import ${what}?\n\nThis replaces your current ${b.full ? 'config and data' : 'config'}.`)) return;
|
||||||
h('div', { class: 'small muted', style: 'margin-bottom:6px' },
|
frozen = true;
|
||||||
'The config as currently edited above. Edit or paste JSON and apply it to the tables, or save it to a file as a backup.'),
|
gmSet('config', b.config);
|
||||||
json,
|
if (b.full) {
|
||||||
h('div', { class: 'btns' },
|
gmSet('state', b.state);
|
||||||
h('button', { onclick: apply }, 'Apply JSON to tables'),
|
for (const k of PREF_KEYS) if (k in b.prefs) gmSet(k, b.prefs[k]);
|
||||||
h('button', { onclick: () => {
|
}
|
||||||
const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(effective(), null, 2)], { type: 'application/json' })),
|
dirty = false;
|
||||||
download: `sas-bonus-watch-${todayISO()}.json` });
|
location.reload();
|
||||||
a.click();
|
}
|
||||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
||||||
} }, 'Download .json'),
|
function resetAll() {
|
||||||
h('button', { onclick: () => file.click() }, 'Load file…'),
|
if (!confirm('Reset everything?\n\nThis permanently deletes your watches, notification targets, tracked matches and ' +
|
||||||
file,
|
'history, and starts over with new random example watches.\n\nUse Export first if you want a backup.')) return;
|
||||||
h('button', { onclick: () => {
|
frozen = true;
|
||||||
if (!confirm('Replace the watches with a new random example? Your notification targets are kept.')) return;
|
gmSet('config', null); // next load generates a new example config
|
||||||
json.value = JSON.stringify({ ...exampleConfig(), notify: effective().notify }, null, 2);
|
gmSet('state', {});
|
||||||
apply();
|
gmSet('collapsed', {});
|
||||||
} }, 'New random example')),
|
gmSet('matchSort', 'departure');
|
||||||
msg);
|
gmSet('paused', false);
|
||||||
|
gmSet('baseline', false);
|
||||||
|
dirty = false;
|
||||||
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
function mount() {
|
function mount() {
|
||||||
@@ -1070,8 +1111,10 @@
|
|||||||
render();
|
render();
|
||||||
} });
|
} });
|
||||||
|
|
||||||
|
const importInput = h('input', { type: 'file', accept: '.json,application/json', style: 'display:none',
|
||||||
|
onchange: (e) => { const f = e.target.files[0]; e.target.value = ''; if (f) importFile(f); } });
|
||||||
ed.tables = h('div');
|
ed.tables = h('div');
|
||||||
ed.errors = h('div', { class: 'err' }, cfgError ? `The saved config has problems:\n${cfgError}` : '');
|
ed.errors = h('div', { class: 'err' }, cfgError ? `The saved config has problems:\n${cfgError}` : cfgNotice);
|
||||||
ed.dirtyNote = h('span', { class: 'warn small' });
|
ed.dirtyNote = h('span', { class: 'warn small' });
|
||||||
ed.save = h('button', { class: 'primary', disabled: true, onclick: () => {
|
ed.save = h('button', { class: 'primary', disabled: true, onclick: () => {
|
||||||
try {
|
try {
|
||||||
@@ -1096,14 +1139,16 @@
|
|||||||
? 'test notification sent' : 'test notification failed') }, 'Test notification'),
|
? 'test notification sent' : 'test notification failed') }, 'Test notification'),
|
||||||
h('button', { onclick: () => { gmSet('baseline', true); log('next sweep records current seats without alerting'); wake?.(); } },
|
h('button', { onclick: () => { gmSet('baseline', true); log('next sweep records current seats without alerting'); wake?.(); } },
|
||||||
'Baseline (no alerts)'),
|
'Baseline (no alerts)'),
|
||||||
h('button', { onclick: () => { if (confirm('Forget seen matches and cached data?')) { gmSet('state', {}); location.reload(); } } },
|
h('span', { class: 'spacer' }),
|
||||||
'Reset state')),
|
h('button', { onclick: exportAll, title: 'Download config, matches and history as one file' }, 'Export'),
|
||||||
|
h('button', { onclick: () => importInput.click(), title: 'Restore from an Export file' }, 'Import'),
|
||||||
|
importInput,
|
||||||
|
h('button', { class: 'danger', onclick: resetAll, title: 'Delete everything and start over' }, 'Reset')),
|
||||||
h('h2', {}, 'Current matches', live.sort), live.matches,
|
h('h2', {}, 'Current matches', live.sort), live.matches,
|
||||||
h('h2', {}, 'Config'),
|
h('h2', {}, 'Config'),
|
||||||
h('div', { class: 'card' }, ed.tables,
|
h('div', { class: 'card' }, ed.tables,
|
||||||
h('div', { class: 'btns', style: 'margin-top:16px' }, ed.save, ed.discard, ed.dirtyNote),
|
h('div', { class: 'btns', style: 'margin-top:16px' }, ed.save, ed.discard, ed.dirtyNote),
|
||||||
ed.errors,
|
ed.errors),
|
||||||
jsonSection()),
|
|
||||||
h('h2', {}, 'Recent changes'), h('div', { class: 'card' }, live.events),
|
h('h2', {}, 'Recent changes'), h('div', { class: 'card' }, live.events),
|
||||||
h('h2', {}, 'Log'), h('div', { class: 'card' }, live.log)));
|
h('h2', {}, 'Log'), h('div', { class: 'card' }, live.log)));
|
||||||
window.addEventListener('beforeunload', (e) => { if (dirty) e.preventDefault(); });
|
window.addEventListener('beforeunload', (e) => { if (dirty) e.preventDefault(); });
|
||||||
|
|||||||
Reference in new issue
Block a user