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:
Måns SivertssonandClaude Opus 5.5 committed 2026-10-13 09:08:47 +02:00
1 parent 06c41615b8
commit 9caff094ac
3 files changed
+135 -64

No files matched your search

+16 -10
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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(); });