From 9caff094ac77f4abff0fcdc1f563019257deeeab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A5ns=20Sivertsson?= Date: Tue, 13 Oct 2026 09:08:47 +0200 Subject: [PATCH] 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 --- README.md | 26 ++++--- sas-bonus-watch.test.cjs | 20 +++++ sas-bonus-watch.user.js | 153 +++++++++++++++++++++++++-------------- 3 files changed, 135 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 8f5fa34..7552a75 100644 --- a/README.md +++ b/README.md @@ -58,15 +58,21 @@ In the **Config** section of the dashboard: 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. -### 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, **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 - **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. - 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* - to move the config elsewhere. +- Config, matches and history live in the userscript manager's storage on that browser only. Use **Export** / + **Import** to back up or move everything to another browser. ## Auto-update 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 // @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`. 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`. ## Development diff --git a/sas-bonus-watch.test.cjs b/sas-bonus-watch.test.cjs index 8f8d8a9..4a29998 100644 --- a/sas-bonus-watch.test.cjs +++ b/sas-bonus-watch.test.cjs @@ -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' }], 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/); +}); diff --git a/sas-bonus-watch.user.js b/sas-bonus-watch.user.js index b81e6f2..3f7b330 100644 --- a/sas-bonus-watch.user.js +++ b/sas-bonus-watch.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name SAS bonus watch // @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. // @author Måns Sivertsson // @homepageURL https://gitea.mscloud.site/mans/sas-bonus-watch @@ -536,7 +536,31 @@ 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 // ================================================================ browser runtime @@ -562,9 +586,18 @@ rawConfig = exampleConfig(); 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 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 function gmPost(url, body, headers = {}) { @@ -673,6 +706,7 @@ async function mainLoop() { if (cfgError) { status.phase = 'config error'; 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 }); 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); @@ -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.primary { background: var(--acc); border-color: var(--acc); color: #fff; } 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; } 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; } @@ -890,17 +926,17 @@ const effective = () => ({ ...draft, watches: draft.watches.filter((w) => !removed.has(w)), 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). */ - function loadDraft(fresh, byName = false) { + /** Start editing `fresh`; remember which saved row each draft row came from. */ + function loadDraft(fresh) { draft = fresh; removed = new Set(); origOf = new WeakMap([[draft.settings, saved.settings]]); 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); }); 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); }); } @@ -973,8 +1009,10 @@ function notifyRow(n) { return h('tr', { class: rowClass(n) }, - h('td', {}, h('select', { ...bind(n, 'type') }, NOTIFY_TYPES.map((t) => - h('option', { value: t, selected: n.type === t }, t)))), + h('td', {}, h('select', { ...bind(n, 'type') }, + (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', {}, removeBtn(draft.notify, n, 'notification'))); } @@ -1014,45 +1052,48 @@ changed(); } - function jsonSection() { - const json = h('textarea', { spellcheck: 'false' }); - const msg = h('div', { class: 'small' }); - const apply = () => { - try { - const parsed = normalizeDraft(JSON.parse(json.value)); - validateConfig(parsed); - loadDraft(parsed, true); - renderEditor(); - msg.className = 'small ok'; - msg.textContent = 'Loaded into the tables above. Press "Save & restart" to use it.'; - } 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) => { - const f = e.target.files[0]; - if (f) { json.value = await f.text(); apply(); } - e.target.value = ''; - } }); - return h('details', { ontoggle: (e) => { if (e.target.open) json.value = JSON.stringify(effective(), null, 2); } }, - h('summary', {}, 'JSON – export / import / reset'), - h('div', { class: 'small muted', style: 'margin-bottom:6px' }, - '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.'), - json, - h('div', { class: 'btns' }, - h('button', { onclick: apply }, 'Apply JSON to tables'), - h('button', { onclick: () => { - const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(effective(), null, 2)], { type: 'application/json' })), - download: `sas-bonus-watch-${todayISO()}.json` }); - a.click(); - setTimeout(() => URL.revokeObjectURL(a.href), 1000); - } }, 'Download .json'), - h('button', { onclick: () => file.click() }, 'Load file…'), - file, - h('button', { onclick: () => { - if (!confirm('Replace the watches with a new random example? Your notification targets are kept.')) return; - json.value = JSON.stringify({ ...exampleConfig(), notify: effective().notify }, null, 2); - apply(); - } }, 'New random example')), - msg); + // ---- export / import / reset ------------------------------------------------- + function exportAll() { + const data = { config: rawConfig, state }; + for (const k of PREF_KEYS) { const v = gmGet(k, null); if (v !== null) data[k] = v; } + const file = { app: 'sas-bonus-watch', format: 1, version: typeof GM_info === 'object' ? GM_info.script?.version : undefined, + exported_at: new Date().toISOString(), data }; + const a = h('a', { href: URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' })), + download: `sas-bonus-watch-${todayISO()}.json` }); + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + log(dirty ? 'exported (saved config only; unsaved edits are not included)' : 'exported config and data'); + } + + async function importFile(f) { + let b; + try { b = parseBackup(await f.text()); } catch (e) { alert(`Import failed: ${e.message}`); return; } + 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'}` + : 'the config only (your current matches and history are kept)'; + if (!confirm(`Import ${what}?\n\nThis replaces your current ${b.full ? 'config and data' : 'config'}.`)) return; + frozen = true; + gmSet('config', b.config); + if (b.full) { + gmSet('state', b.state); + for (const k of PREF_KEYS) if (k in b.prefs) gmSet(k, b.prefs[k]); + } + dirty = false; + location.reload(); + } + + function resetAll() { + if (!confirm('Reset everything?\n\nThis permanently deletes your watches, notification targets, tracked matches and ' + + 'history, and starts over with new random example watches.\n\nUse Export first if you want a backup.')) return; + frozen = true; + gmSet('config', null); // next load generates a new example config + gmSet('state', {}); + gmSet('collapsed', {}); + gmSet('matchSort', 'departure'); + gmSet('paused', false); + gmSet('baseline', false); + dirty = false; + location.reload(); } function mount() { @@ -1070,8 +1111,10 @@ 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.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.save = h('button', { class: 'primary', disabled: true, onclick: () => { try { @@ -1096,14 +1139,16 @@ ? 'test notification sent' : 'test notification failed') }, 'Test notification'), h('button', { onclick: () => { gmSet('baseline', true); log('next sweep records current seats without alerting'); wake?.(); } }, 'Baseline (no alerts)'), - h('button', { onclick: () => { if (confirm('Forget seen matches and cached data?')) { gmSet('state', {}); location.reload(); } } }, - 'Reset state')), + h('span', { class: 'spacer' }), + 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', {}, 'Config'), h('div', { class: 'card' }, ed.tables, h('div', { class: 'btns', style: 'margin-top:16px' }, ed.save, ed.discard, ed.dirtyNote), - ed.errors, - jsonSection()), + ed.errors), h('h2', {}, 'Recent changes'), h('div', { class: 'card' }, live.events), h('h2', {}, 'Log'), h('div', { class: 'card' }, live.log))); window.addEventListener('beforeunload', (e) => { if (dirty) e.preventDefault(); });