diff --git a/README.md b/README.md index f3402f0..2cec57d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A Tampermonkey / Violentmonkey userscript that watches SAS EuroBonus award avail seats you care about appear. It runs inside your own browser, so requests to sas.se are ordinary same-origin browser requests and it keeps working behind sas.se's Cloudflare protection. -- Any number of **watches**: origins, destinations, one travel window, cabins (Go / Plus / Business), seats, max stops +- Any number of **watches**: origins, destinations, one travel window, cabins (Economy / Premium / Business), seats, max stops - Finds **outbound and return** flights inside the window; optionally only alerts when both legs exist, with min/max nights - **Gentle on sas.se**: about one request a minute, a full sweep at most every 15 minutes, backs off when refused - Alerts via **ntfy** (phone push), **Discord**, **Slack**, any **webhook**, and desktop notifications @@ -34,7 +34,7 @@ In the **Config** section of the dashboard: | On | Pause a watch without deleting it | | From / To | Airport codes, comma-separated (`ARN, CPH`). Every From × To pair is checked | | Earliest / Latest | Travel window. Outbound **and** return flights departing in it are found | - | Cabins | Go (AG), Plus (AP), Business (AB) | + | Cabins | Economy, Premium (premium economy), Business | | Seats | Seats needed **on the same flight** | | Stops | Any, direct only, or at most 1 / 2 stops | | Both legs | Only alert when an outbound has a later return in the window (default on). Off = alert for either direction on its own | @@ -60,10 +60,15 @@ Under the tables, **JSON – export / import / reset** holds the same config as *Download .json* for a backup, *Load file…* or paste + *Apply JSON to tables* to restore, *Reset to example* to start over. Nothing takes effect until you press **Save & restart**. +Fields that differ from the saved config are highlighted in yellow until you save or discard; new rows are +highlighted in full. + ## Dashboard -- **Current matches** per watch: - - `Go ×8 −2` – bookable now; the red number is how many seats were taken since the highest count seen +- **Current matches** per watch, collapsed to a summary row by default (click the header or the summary to expand): + - summary: number of flights, bookable seats per cabin with how many were lost in red (`Economy ×30 −6`), and + when the most recent flight appeared + - `Economy ×8 −2` – bookable now; the red number is how many seats were taken since the highest count seen - red, struck through, *gone 12 okt 13:42* – sold out (or below your seat count); kept for two weeks or until departure - grey, *no matching return* – still has seats, but its return disappeared (with Both legs) - **Recent changes**: `+` new / back, `▲` more seats, `▼` fewer seats, `−` gone, `○` lost its return @@ -98,7 +103,7 @@ Only one tab polls at a time, so opening the dashboard twice does not double the The script header points Tampermonkey at this repo: ``` -// @version 1.1.1 +// @version 1.2.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 ``` @@ -121,7 +126,7 @@ Requirements: 1. Edit `sas-bonus-watch.user.js`. 2. Run the tests: `node --test` (Node 20+). -3. **Bump `@version`** (e.g. 1.1.1 → 1.1.2). Without this, nobody gets the update. +3. **Bump `@version`** (e.g. 1.2.0 → 1.2.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 4c39439..87f3a4d 100644 --- a/sas-bonus-watch.test.cjs +++ b/sas-bonus-watch.test.cjs @@ -50,7 +50,7 @@ test('new seats notify once, unchanged calendar uses cache', async () => { const { w, calls, sent } = setup([watch()], baseResponses()); assert.equal(await w.runCycle(), 2); assert.equal(sent.length, 1); - assert.match(sent[0].body, /SAS Plus ×1/); + assert.match(sent[0].body, /Premium ×1/); assert.match(sent[0].body, /OUT ARN→HND Wed 09 Dec 06:00→Thu 10 Dec 07:55, 1 stop/); assert.deepEqual(calls, ['cal ARN HND', 'routes ARN HND 2026-12-09']); // 12-08 has only AG -> never fetched @@ -167,7 +167,7 @@ test('seat drops are tracked against the peak, gone matches stay listed in red', const e = state.matches.tokyo['outbound|SK1415-20261209-ARN-CPH|AP']; assert.deepEqual([e.status, e.seats, e.peak], ['active', 8, 10]); assert.equal(state.events[0].kind, 'down'); - assert.match(state.events[0].line, /SAS Plus ×10 → ×8/); + assert.match(state.events[0].line, /Premium ×10 → ×8/); assert.equal(sent.length, 1); // a drop is shown, not alerted r['cal ARN HND'] = calendarPayload([], []); @@ -209,3 +209,12 @@ test('a leg whose return disappeared is marked unpaired, not gone', async () => const st = Object.fromEntries(Object.entries(state.matches.tokyo).map(([k, e]) => [k.split('|')[1], e.status])); assert.deepEqual(st, { 'SK1415-20261209-ARN-CPH': 'unpaired', 'SK1417-20261209-ARN-CPH': 'unpaired', 'SK984-20261220-HND-CPH': 'gone' }); }); + +test('cabins are configured by name; old codes and SAS names still work', () => { + const cabinsOf = (cabins) => core.validateConfig({ watches: [watch({ cabins })] }).watches[0].cabins; + assert.deepEqual(cabinsOf(['business', 'Economy']), ['AG', 'AB']); + assert.deepEqual(cabinsOf(['AP', 'go', 'Plus']), ['AG', 'AP']); + assert.deepEqual(cabinsOf(undefined), ['AG', 'AP', 'AB']); + assert.throws(() => cabinsOf(['first']), /unknown cabin "first", use economy, premium or business/); + assert.deepEqual(core.CABINS, { AG: 'Economy', AP: 'Premium', AB: 'Business' }); +}); diff --git a/sas-bonus-watch.user.js b/sas-bonus-watch.user.js index 7fdc0b9..ad88cca 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.1.1 +// @version 1.2.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 @@ -36,7 +36,13 @@ (function () { 'use strict'; - const CABINS = { AG: 'SAS Go', AP: 'SAS Plus', AB: 'SAS Business' }; + // SAS award-finder cabin codes -> display names, and the names used in the config + const CABINS = { AG: 'Economy', AP: 'Premium', AB: 'Business' }; + const CABIN_KEYS = { AG: 'economy', AP: 'premium', AB: 'business' }; + const CABIN_ALIASES = { economy: 'AG', go: 'AG', ag: 'AG', premium: 'AP', 'premium economy': 'AP', plus: 'AP', ap: 'AP', + business: 'AB', ab: 'AB' }; + const cabinCode = (c) => CABIN_ALIASES[String(c).trim().toLowerCase()]; + const cabinKey = (c) => CABIN_KEYS[cabinCode(c)] || String(c); const DEFAULT_CONFIG = { settings: { @@ -59,7 +65,7 @@ destinations: ['HND', 'NRT'], from: '2026-12-15', to: '2027-01-10', - cabins: ['AP', 'AB'], + cabins: ['premium', 'business'], min_seats: 2, max_stops: 1, require_both_legs: true, @@ -128,9 +134,10 @@ const name = String(w.name || '').trim() || `watch ${i + 1}`; const at = `"${name}"`; Object.keys(w).filter((k) => !WATCH_KEYS.includes(k)).forEach((k) => err(`${at}: unknown key "${k}"`)); - const cabins = (w.cabins || Object.keys(CABINS)).map((c) => String(c).toUpperCase()); - if (!cabins.length) err(`${at}: pick at least one cabin`); - cabins.filter((c) => !CABINS[c]).forEach((c) => err(`${at}: unknown cabin "${c}", use AG, AP or AB`)); + const rawCabins = w.cabins || Object.values(CABIN_KEYS); + if (!rawCabins.length) err(`${at}: pick at least one cabin`); + rawCabins.filter((c) => !cabinCode(c)).forEach((c) => err(`${at}: unknown cabin "${c}", use economy, premium or business`)); + const cabins = Object.keys(CABINS).filter((code) => rawCabins.some((c) => cabinCode(c) === code)); if (!dateOk(w.from) || !dateOk(w.to)) err(`${at}: "from" and "to" must be dates (YYYY-MM-DD)`); else if (w.to < w.from) err(`${at}: "to" is before "from"`); const window = { from: w.from, to: w.to }; @@ -436,7 +443,7 @@ return { runCycle, estimate }; } - const core = { validateConfig, migrateWatch, parseCalendar, parseFlight, createWatcher, legNeeds, applyRoundTrip, DEFAULT_CONFIG }; + const core = { CABINS, validateConfig, migrateWatch, parseCalendar, parseFlight, createWatcher, legNeeds, applyRoundTrip, DEFAULT_CONFIG }; if (typeof module === 'object' && module.exports) { module.exports = core; return; } // node tests // ================================================================ browser runtime @@ -607,7 +614,8 @@ const h = (tag, attrs = {}, ...kids) => { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { - if (k.startsWith('on')) el.addEventListener(k.slice(2), v); + if (k === '$bind') bound.set(el, v); + else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else if (k === 'checked' || k === 'disabled' || k === 'value' || k === 'selected') el[k] = v; else if (v != null && v !== false) el.setAttribute(k, v === true ? '' : v); } @@ -632,13 +640,20 @@ button.icon { padding: 2px 8px; color: var(--muted); } 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 0; 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; } th { font-weight: 600; color: var(--muted); font-size: 12px; white-space: nowrap; } input, select { font: inherit; font-size: 13px; color: var(--fg); background: var(--input); border: 1px solid var(--line); border-radius: 5px; padding: 3px 5px; } input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--acc); vertical-align: -3px; } input.code { width: 8.5em; text-transform: uppercase; } input.name { width: 11em; } input.num { width: 4.2em; } input.url { width: 100%; min-width: 22em; } label.cab { white-space: nowrap; margin-right: 6px; font-size: 13px; } tr.off td { opacity: .45; } tr.off td:first-child { opacity: 1; } + .changed, tr.added td { background: color-mix(in srgb, var(--warn) 13%, transparent); } + td.changed, label.changed { border-radius: 4px; } label.changed { padding: 2px 4px; } + .changed > input:not([type=checkbox]), .changed > select, tr.added input:not([type=checkbox]), tr.added select { + background: color-mix(in srgb, var(--warn) 15%, var(--input)); border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); } + .toggle { cursor: pointer; user-select: none; } + .chev { display: inline-block; width: 1.1em; color: var(--muted); } + tr.sum td { font-weight: 600; } tr.sum td.muted { font-weight: 400; } td.seats { white-space: nowrap; font-variant-numeric: tabular-nums; } tr.gone td { color: var(--bad); } tr.gone td.desc, tr.gone td.seats { text-decoration: line-through; } tr.unpaired td.desc, tr.unpaired td.seats { color: var(--muted); } @@ -665,28 +680,51 @@ const when = (iso) => new Date(iso).toLocaleString('sv-SE', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); const order = { active: 0, unpaired: 1, gone: 2 }; + const collapsed = gmGet('collapsed', {}); // per watch; collapsed unless explicitly opened live.matches.replaceChildren(...(cfg?.watches || []).map((w) => { const rows = Object.values(state.matches?.[w.name] || {}).map((r) => ({ status: 'active', desc: r.line, peak: r.seats, ...r })) .sort((a, b) => order[a.status] - order[b.status] || (a.leg > b.leg ? -1 : a.leg < b.leg ? 1 : 0) || (a.date + a.time).localeCompare(b.date + b.time)); - const active = rows.filter((r) => r.status === 'active').length; + const open = collapsed[w.name] === false; + const toggle = () => { collapsed[w.name] = open; gmSet('collapsed', collapsed); render(); }; const seatsCell = (r) => h('td', { class: 'seats' }, - r.cabin ? `${CABINS[r.cabin].replace('SAS ', '')} ×${r.seats}` : '', + r.cabin ? `${CABINS[r.cabin]} ×${r.seats}` : '', r.status !== 'gone' && r.peak > r.seats ? h('span', { class: 'bad', title: `was ×${r.peak}` }, ` −${r.peak - r.seats}`) : null); const statusCell = (r) => r.status === 'gone' ? h('td', { class: 'bad' }, `gone ${when(r.since)}`) : r.status === 'unpaired' ? h('td', { class: 'muted', title: 'Still has seats, but no return in your window fits with it' }, 'no matching return') : h('td', { class: 'muted' }, `since ${when(r.first)}`); + + // summary: bookable seats per cabin, and how many were lost (drops + sold-out flights) + const byCabin = {}; + for (const r of rows) { + if (!r.cabin) continue; + const c = (byCabin[r.cabin] ||= { seats: 0, missed: 0 }); + if (r.status === 'active') c.seats += r.seats; + c.missed += r.status === 'gone' ? r.peak : r.peak - r.seats; + } + const count = (st) => rows.filter((r) => r.status === st).length; + const latest = rows.filter((r) => r.status === 'active').map((r) => r.since || r.first).sort().pop(); + const summary = h('tr', { class: 'sum toggle', onclick: toggle, title: open ? 'Collapse' : 'Expand' }, + h('td', {}, h('span', { class: 'chev' }, open ? '▾' : '▸'), + `${rows.length} flight${rows.length === 1 ? '' : 's'} · ${count('active')} bookable` + + (count('gone') ? ` · ${count('gone')} gone` : '') + (count('unpaired') ? ` · ${count('unpaired')} without return` : '')), + h('td', { class: 'seats' }, Object.keys(CABINS).filter((c) => byCabin[c]).flatMap((c, i) => [ + i ? ' · ' : '', `${CABINS[c]} ×${byCabin[c].seats}`, + byCabin[c].missed > 0 ? h('span', { class: 'bad', title: 'seats taken or sold out since first seen' }, ` −${byCabin[c].missed}`) : null])), + h('td', { class: 'muted' }, latest ? `since ${when(latest)}` : '–')); + return h('div', { class: 'card', style: 'margin-bottom:10px' }, - h('strong', {}, w.name), ' ', - h('span', { class: 'muted' }, `${w.origins.join('/')} ⇄ ${w.destinations.join('/')} · ${w.window.from} → ${w.window.to}` + - ` · ${w.cabins.join('/')} · ≥${w.min_seats} seat(s)` + (w.require_both_legs ? ' · both legs' : '') + - ` · ${active} bookable`), + h('div', { class: 'toggle', onclick: toggle }, + h('strong', {}, w.name), ' ', + h('span', { class: 'muted' }, `${w.origins.join('/')} ⇄ ${w.destinations.join('/')} · ${w.window.from} → ${w.window.to}` + + ` · ${w.cabins.map((c) => CABINS[c]).join('/')} · ≥${w.min_seats} seat(s)` + (w.require_both_legs ? ' · both legs' : ''))), rows.length ? h('div', { class: 'scroll' }, h('table', {}, h('tr', {}, h('th', {}, 'Flight'), h('th', {}, 'Seats'), h('th', {}, 'Status')), - rows.map((r) => h('tr', { class: r.status }, h('td', { class: 'desc' }, r.desc), seatsCell(r), statusCell(r))))) + summary, + open ? rows.map((r) => h('tr', { class: r.status }, h('td', { class: 'desc' }, r.desc), seatsCell(r), statusCell(r))) : [])) : h('div', { class: 'muted', style: 'margin-top:6px' }, 'No matching seats right now.')); })); @@ -705,44 +743,74 @@ const normalizeDraft = (raw) => { const d = clone(raw || {}); d.settings = { ...DEFAULT_CONFIG.settings, ...(d.settings || {}) }; - d.notify = d.notify || []; - d.watches = (d.watches || []).map(migrateWatch).map((w) => ({ enabled: true, require_both_legs: true, ...w })); + d.notify = (d.notify || []).map((n) => ({ type: 'ntfy', url: '', priority: '', ...n })); + d.watches = (d.watches || []).map(migrateWatch).map((w) => ({ + enabled: true, require_both_legs: true, min_seats: 1, max_stops: null, min_nights: null, max_nights: null, ...w, + cabins: (w.cabins || Object.values(CABIN_KEYS)).map(cabinKey), + })); return d; }; - let draft = normalizeDraft(rawConfig); - let dirty = false; + const canon = (v) => JSON.stringify(v, (k, x) => (x === '' || x === undefined ? null : x)); + const saved = normalizeDraft(rawConfig); + const bound = new Map(); // input/cell -> [object, key], for highlighting unsaved fields const ed = {}; + let draft, origOf, dirty = false; - const setDirty = (on) => { - dirty = on; - ed.save.disabled = ed.discard.disabled = !on; - ed.dirtyNote.textContent = on ? 'Unsaved changes' : ''; - if (on) ed.errors.textContent = ''; - }; + /** 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) { + draft = fresh; + 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]; + 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]; + if (o) origOf.set(n, o); + }); + } + loadDraft(normalizeDraft(rawConfig)); + + const differs = (obj, key) => { const o = origOf.get(obj); return !o || canon(obj[key]) !== canon(o[key]); }; + + function changed() { + dirty = canon(draft) !== canon(saved); + ed.save.disabled = ed.discard.disabled = !dirty; + const kept = [...draft.watches, ...draft.notify].filter((x) => origOf.has(x)).length; + const removed = saved.watches.length + saved.notify.length - kept; + ed.dirtyNote.textContent = dirty ? `Unsaved changes${removed > 0 ? ` · ${removed} row${removed > 1 ? 's' : ''} removed` : ''}` : ''; + if (dirty) ed.errors.textContent = ''; + for (const [el, [obj, key]] of bound) { + if (!el.isConnected) { bound.delete(el); continue; } + const target = el.matches('td, label') ? el : el.closest('td, label') || el; + target.classList.toggle('changed', differs(obj, key)); + } + } const bind = (obj, key, parse = (v) => v) => ({ - oninput: (e) => { obj[key] = parse(e.target.type === 'checkbox' ? e.target.checked : e.target.value); setDirty(true); }, + $bind: [obj, key], + oninput: (e) => { obj[key] = parse(e.target.type === 'checkbox' ? e.target.checked : e.target.value); changed(); }, }); const optInt = (v) => (v === '' ? null : Number(v)); const codes = (v) => String(v).split(/[\s,;/]+/).map((c) => c.trim().toUpperCase()).filter(Boolean); - const removeBtn = (list, i, title) => h('button', { class: 'icon', title, onclick: () => { list.splice(i, 1); setDirty(true); renderEditor(); } }, '✕'); + const removeBtn = (list, i, title) => h('button', { class: 'icon', title, onclick: () => { list.splice(i, 1); renderEditor(); } }, '✕'); function watchRow(w, i) { - const cabinBoxes = Object.entries(CABINS).map(([code, label]) => h('label', { class: 'cab', title: label }, - h('input', { type: 'checkbox', checked: (w.cabins || []).includes(code), onchange: (e) => { + const cabinBoxes = Object.entries(CABIN_KEYS).map(([code, key]) => h('label', { class: 'cab' }, + h('input', { type: 'checkbox', checked: (w.cabins || []).includes(key), onchange: (e) => { const set = new Set(w.cabins || []); - if (e.target.checked) set.add(code); else set.delete(code); - w.cabins = Object.keys(CABINS).filter((c) => set.has(c)); - setDirty(true); - } }), ` ${label.replace('SAS ', '')}`)); - const tr = h('tr', { class: w.enabled === false ? 'off' : null }, - h('td', {}, h('input', { type: 'checkbox', title: 'Enabled', checked: w.enabled !== false, - onchange: (e) => { w.enabled = e.target.checked; tr.className = w.enabled ? '' : 'off'; setDirty(true); } })), + if (e.target.checked) set.add(key); else set.delete(key); + w.cabins = Object.values(CABIN_KEYS).filter((c) => set.has(c)); + changed(); + } }), ` ${CABINS[code]}`)); + const tr = h('tr', { class: [w.enabled === false ? 'off' : '', origOf.has(w) ? '' : 'added'].join(' ').trim() || null }, + h('td', {}, h('input', { type: 'checkbox', title: 'Enabled', checked: w.enabled !== false, $bind: [w, 'enabled'], + onchange: (e) => { w.enabled = e.target.checked; tr.classList.toggle('off', !w.enabled); changed(); } })), h('td', {}, h('input', { class: 'name', value: w.name || '', placeholder: 'Name', ...bind(w, 'name') })), h('td', {}, h('input', { class: 'code', value: (w.origins || []).join(', '), placeholder: 'ARN, CPH', ...bind(w, 'origins', codes) })), h('td', {}, h('input', { class: 'code', value: (w.destinations || []).join(', '), placeholder: 'HND, NRT', ...bind(w, 'destinations', codes) })), h('td', {}, h('input', { type: 'date', value: w.from || '', ...bind(w, 'from') })), h('td', {}, h('input', { type: 'date', value: w.to || '', ...bind(w, 'to') })), - h('td', { style: 'white-space:nowrap' }, cabinBoxes), + h('td', { style: 'white-space:nowrap', $bind: [w, 'cabins'] }, cabinBoxes), h('td', {}, h('input', { class: 'num', type: 'number', min: 1, max: 9, value: w.min_seats ?? 1, ...bind(w, 'min_seats', Number) })), h('td', {}, h('select', { ...bind(w, 'max_stops', optInt) }, [['', 'Any'], ['0', 'Direct'], ['1', '≤ 1'], ['2', '≤ 2']].map(([v, l]) => @@ -758,7 +826,7 @@ } function notifyRow(n, i) { - return h('tr', {}, + return h('tr', { class: origOf.has(n) ? null : 'added' }, h('td', {}, h('select', { ...bind(n, 'type') }, ['ntfy', 'discord', 'slack', 'webhook'].map((t) => h('option', { value: t, selected: n.type === t }, t)))), h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'https://ntfy.sh/your-topic', ...bind(n, 'url') })), @@ -782,8 +850,8 @@ h('div', { class: 'btns' }, h('button', { onclick: () => { const today = todayISO(); draft.watches.push({ name: `watch ${draft.watches.length + 1}`, enabled: true, origins: ['ARN'], destinations: [], - from: today, to: addDays(today, 14), cabins: ['AG', 'AP', 'AB'], min_seats: 1, max_stops: null, require_both_legs: true }); - setDirty(true); + from: today, to: addDays(today, 14), cabins: Object.values(CABIN_KEYS), min_seats: 1, max_stops: null, + require_both_legs: true, min_nights: null, max_nights: null }); renderEditor(); } }, '+ Add watch')), @@ -792,7 +860,7 @@ h('tr', {}, ['Type', 'URL', 'Priority', ''].map((c) => h('th', {}, c))), draft.notify.map(notifyRow))), h('div', { class: 'btns' }, - h('button', { onclick: () => { draft.notify.push({ type: 'ntfy', url: '', priority: 'high' }); setDirty(true); renderEditor(); } }, + h('button', { onclick: () => { draft.notify.push({ type: 'ntfy', url: '', priority: 'high' }); renderEditor(); } }, '+ Add notification'), h('label', {}, h('input', { type: 'checkbox', checked: st.desktop_notifications, ...bind(st, 'desktop_notifications') }), ' Desktop notifications')), @@ -802,6 +870,7 @@ numSetting('min_request_interval', 'Seconds between requests (≥ 20)', 20), numSetting('jitter', '+ random up to', 0), numSetting('cycle_interval', 'Seconds between sweeps', 60)))); + changed(); } function jsonSection() { @@ -811,8 +880,7 @@ try { const parsed = normalizeDraft(JSON.parse(json.value)); validateConfig(parsed); - draft = parsed; - setDirty(true); + loadDraft(parsed, true); renderEditor(); msg.className = 'small ok'; msg.textContent = 'Loaded into the tables above. Press "Save & restart" to use it.'; @@ -869,10 +937,9 @@ } catch (e) { ed.errors.textContent = e.message; } } }, 'Save & restart'); ed.discard = h('button', { disabled: true, onclick: () => { - draft = normalizeDraft(rawConfig); - setDirty(false); - ed.errors.textContent = ''; + loadDraft(normalizeDraft(rawConfig)); renderEditor(); + ed.errors.textContent = ''; } }, 'Discard changes'); document.body.replaceChildren(h('main', {},