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
+99
-54
@@ -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(); });
|
||||
|
||||
Reference in new issue
Block a user