Random themed example watches, Zulip instead of ntfy
- A fresh install generates two example watches from ARN: a themed, funny name (Christmas, New Year, getaway weekend, sun escape, ski week, Valentine's, Easter, Midsummer, bucket list), a fitting SAS award destination and dates at the theme's next occurrence; saved on first run. "New random example" regenerates the watches and keeps targets. - Notifications: ntfy and its priority column removed (ntfy entries get a clear validation error); new zulip type for Zulip's Slack-compatible webhook, with one topic per watch - Wider name field Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
05d1d28522
commit
06c41615b8
3 files changed
+202
-49
No files matched your search
@@ -7,7 +7,7 @@ browser requests and it keeps working behind sas.se's Cloudflare protection.
|
|||||||
- Any number of **watches**: origins, destinations, one travel window, cabins (Economy / Premium / 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
|
- 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
|
- **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
|
- Alerts via **Zulip**, **Slack**, **Discord**, any **webhook**, and desktop notifications
|
||||||
- Dashboard shows what is bookable now **and what you missed**: seat drops and sold-out flights
|
- Dashboard shows what is bookable now **and what you missed**: seat drops and sold-out flights
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
@@ -25,9 +25,13 @@ browser requests and it keeps working behind sas.se's Cloudflare protection.
|
|||||||
|
|
||||||
## Set up
|
## Set up
|
||||||
|
|
||||||
|
On first start the script creates two example watches from ARN with a random theme, a destination that suits it
|
||||||
|
and dates at its next occurrence – e.g. *Christmas in Rovaniemi* (20–30 Dec), *Getaway weekend in Prague* (a Friday
|
||||||
|
→ Monday a few weeks out), *Ski week in Innsbruck* (sportlov), *Valentine's escape to Paris*. Keep, edit or delete them.
|
||||||
|
|
||||||
In the **Config** section of the dashboard:
|
In the **Config** section of the dashboard:
|
||||||
|
|
||||||
1. **Watches** – one row per search:
|
1. **Watches** – each watch is two lines of fields:
|
||||||
|
|
||||||
| Column | Meaning |
|
| Column | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -44,9 +48,9 @@ In the **Config** section of the dashboard:
|
|||||||
|
|
||||||
| Type | URL |
|
| Type | URL |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ntfy` | `https://ntfy.sh/<long-random-topic>`. Install the ntfy app and subscribe to the same topic. Priority sets how loud it is |
|
| `zulip` | Zulip's Slack-compatible webhook: `https://<org>.zulipchat.com/api/v1/external/slack_incoming?api_key=<bot key>&stream=<channel>`. Each watch posts to a topic named after it, unless you add `&topic=…` |
|
||||||
| `discord` | A channel webhook URL |
|
|
||||||
| `slack` | An incoming-webhook URL |
|
| `slack` | An incoming-webhook URL |
|
||||||
|
| `discord` | A channel webhook URL |
|
||||||
| `webhook` | Anything; receives `POST` JSON `{title, body, watch, matches: [...]}` |
|
| `webhook` | Anything; receives `POST` JSON `{title, body, watch, matches: [...]}` |
|
||||||
|
|
||||||
The first time the script posts to a host, Tampermonkey asks you to allow it — choose *Always allow domain*.
|
The first time the script posts to a host, Tampermonkey asks you to allow it — choose *Always allow domain*.
|
||||||
@@ -58,7 +62,7 @@ In the **Config** section of the dashboard:
|
|||||||
|
|
||||||
Under the tables, **JSON – export / import / reset** holds the same config as JSON:
|
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,
|
*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**.
|
*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).
|
||||||
@@ -104,7 +108,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:
|
The script header points Tampermonkey at this repo:
|
||||||
|
|
||||||
```
|
```
|
||||||
// @version 1.3.1
|
// @version 1.4.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
|
||||||
```
|
```
|
||||||
@@ -127,7 +131,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.3.1 → 1.3.2). Without this, nobody gets the update.
|
3. **Bump `@version`** (e.g. 1.4.0 → 1.4.1). Without this, nobody gets the update.
|
||||||
4. Commit and push to `main`.
|
4. Commit and push to `main`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -218,3 +218,55 @@ test('cabins are configured by name; old codes and SAS names still work', () =>
|
|||||||
assert.throws(() => cabinsOf(['first']), /unknown cabin "first", use economy, premium or business/);
|
assert.throws(() => cabinsOf(['first']), /unknown cabin "first", use economy, premium or business/);
|
||||||
assert.deepEqual(core.CABINS, { AG: 'Economy', AP: 'Premium', AB: 'Business' });
|
assert.deepEqual(core.CABINS, { AG: 'Economy', AP: 'Premium', AB: 'Business' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// every award destination SAS listed from ARN on 2026-10-13 (award-finder destinations/v1)
|
||||||
|
const SAS_FROM_ARN = new Set(('AGA ALC ALF AMS AYT ATH ATL BCN BRI BGO BER BIO BLL BHX BOO BLQ BOD BOS BRU BUD CTA CHQ ORD DUB DUS EDI FAO ' +
|
||||||
|
'FLR FRA FUE FAE GZP GDN GVA GOT HAD HAM HAJ EVE HAU HEL INN KLR KKN KRN KRK KRS KSU CPH LCA LPA LIS LHR LYR LAX LLA LUX LYS FNC ' +
|
||||||
|
'MAD AGP MMX MLA MAN RAK MIA MXP LIN BOM MUC NAP EWR JFK NCE OLB OSL PLQ PMO PMI CDG PSA OPO POZ PRG PUY KEF RHO FCO RNB RVN SZG ' +
|
||||||
|
'SFO SEA ICN SVQ SFT SPU SVG STR SDL SCR TLL TLV TFS SKG HND YYZ TOS TRD UME VAA VCE VNO VBY WAW IAD VIE WRO ZRH AGH TKU AAL AES ' +
|
||||||
|
'AAR OSD').split(' '));
|
||||||
|
|
||||||
|
const seeded = (seed) => () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
|
||||||
|
|
||||||
|
test('example destinations are all SAS award destinations from ARN', () => {
|
||||||
|
for (const t of core.THEMES) for (const code of t.places) assert.ok(SAS_FROM_ARN.has(code) && core.PLACES[code], code);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('date rules for yearly themes', () => {
|
||||||
|
assert.equal(core.easterSunday(2027), '2027-03-28');
|
||||||
|
assert.equal(core.easterSunday(2028), '2028-04-16');
|
||||||
|
const win = (i, y) => core.THEMES[i].window(y, '2026-10-13', () => 0);
|
||||||
|
assert.deepEqual(win(0, 2026), { from: '2026-12-20', to: '2026-12-30' }); // Christmas
|
||||||
|
assert.deepEqual(win(4, 2027), { from: '2027-02-27', to: '2027-03-07' }); // sportlov, ISO week 9
|
||||||
|
assert.deepEqual(win(6, 2027), { from: '2027-03-25', to: '2027-03-29' }); // Maundy Thursday -> Easter Monday
|
||||||
|
assert.deepEqual(win(7, 2027), { from: '2027-06-24', to: '2027-06-27' }); // around Midsummer Eve, Fri 25 June
|
||||||
|
const weekend = core.THEMES[2].window(2026, '2026-10-13', () => 0);
|
||||||
|
assert.deepEqual(weekend, { from: '2026-11-06', to: '2026-11-09' }); // first Friday ≥ 3 weeks out, through Monday
|
||||||
|
});
|
||||||
|
|
||||||
|
test('example configs are valid, near-future, themed and different each time', () => {
|
||||||
|
const today = '2026-10-13';
|
||||||
|
const names = new Set();
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const cfg = core.exampleConfig(today, seeded(seed));
|
||||||
|
const valid = core.validateConfig(cfg);
|
||||||
|
assert.equal(valid.watches.length, 2);
|
||||||
|
assert.deepEqual(cfg.notify, []);
|
||||||
|
for (const w of cfg.watches) {
|
||||||
|
assert.deepEqual(w.origins, ['ARN']);
|
||||||
|
assert.ok(w.from >= '2026-10-27' && w.from <= '2027-05-11', `${w.name} ${w.from}`);
|
||||||
|
assert.ok(w.to > w.from);
|
||||||
|
assert.ok(w.name.includes(core.PLACES[w.destinations[0]]), w.name);
|
||||||
|
names.add(w.name);
|
||||||
|
}
|
||||||
|
assert.notEqual(cfg.watches[0].name, cfg.watches[1].name);
|
||||||
|
}
|
||||||
|
assert.ok(names.size > 40, `only ${names.size} distinct names`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ntfy targets are rejected with a clear message; zulip is accepted', () => {
|
||||||
|
assert.throws(() => core.validateConfig({ notify: [{ type: 'ntfy', url: 'https://ntfy.sh/x' }], watches: [watch()] }),
|
||||||
|
/ntfy is no longer supported/);
|
||||||
|
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');
|
||||||
|
});
|
||||||
+139
-42
@@ -1,7 +1,7 @@
|
|||||||
// ==UserScript==
|
// ==UserScript==
|
||||||
// @name SAS bonus watch
|
// @name SAS bonus watch
|
||||||
// @namespace sas-bonus-finder
|
// @namespace sas-bonus-finder
|
||||||
// @version 1.3.1
|
// @version 1.4.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
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
// @grant GM_setValue
|
// @grant GM_setValue
|
||||||
// @grant GM_xmlhttpRequest
|
// @grant GM_xmlhttpRequest
|
||||||
// @grant GM_notification
|
// @grant GM_notification
|
||||||
// @connect ntfy.sh
|
|
||||||
// @connect discord.com
|
// @connect discord.com
|
||||||
// @connect hooks.slack.com
|
// @connect hooks.slack.com
|
||||||
// @connect *
|
// @connect *
|
||||||
@@ -44,6 +43,8 @@
|
|||||||
const cabinCode = (c) => CABIN_ALIASES[String(c).trim().toLowerCase()];
|
const cabinCode = (c) => CABIN_ALIASES[String(c).trim().toLowerCase()];
|
||||||
const cabinKey = (c) => CABIN_KEYS[cabinCode(c)] || String(c);
|
const cabinKey = (c) => CABIN_KEYS[cabinCode(c)] || String(c);
|
||||||
|
|
||||||
|
const NOTIFY_TYPES = ['zulip', 'slack', 'discord', 'webhook'];
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
settings: {
|
settings: {
|
||||||
market: 'se-sv',
|
market: 'se-sv',
|
||||||
@@ -54,25 +55,8 @@
|
|||||||
prefilter: true,
|
prefilter: true,
|
||||||
desktop_notifications: true,
|
desktop_notifications: true,
|
||||||
},
|
},
|
||||||
notify: [
|
notify: [],
|
||||||
{ type: 'ntfy', url: 'https://ntfy.sh/CHANGE-ME-to-a-long-random-topic', priority: 'high' },
|
watches: [], // a fresh install gets exampleConfig() instead
|
||||||
],
|
|
||||||
watches: [
|
|
||||||
{
|
|
||||||
name: 'Tokyo over Christmas',
|
|
||||||
enabled: true,
|
|
||||||
origins: ['ARN', 'CPH'],
|
|
||||||
destinations: ['HND', 'NRT'],
|
|
||||||
from: '2026-12-15',
|
|
||||||
to: '2027-01-10',
|
|
||||||
cabins: ['premium', 'business'],
|
|
||||||
min_seats: 2,
|
|
||||||
max_stops: 1,
|
|
||||||
require_both_legs: true,
|
|
||||||
min_nights: 10,
|
|
||||||
max_nights: 21,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------- dates
|
// ---------------------------------------------------------------- dates
|
||||||
@@ -91,6 +75,114 @@
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- example watches
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A fresh install (and "New random example") gets a few themed watches from ARN with a destination that
|
||||||
|
* suits the name and dates at the next occurrence of the theme. Every destination below is an SAS award
|
||||||
|
* destination from ARN (award-finder destinations list, October 2026).
|
||||||
|
*/
|
||||||
|
const PLACES = {
|
||||||
|
AGA: 'Agadir', AGP: 'Málaga', AMS: 'Amsterdam', ATH: 'Athens', BCN: 'Barcelona', BER: 'Berlin', BOO: 'Bodø',
|
||||||
|
BUD: 'Budapest', CDG: 'Paris', DUB: 'Dublin', EDI: 'Edinburgh', EWR: 'New York', FCO: 'Rome', FLR: 'Florence',
|
||||||
|
FNC: 'Madeira', FUE: 'Fuerteventura', GVA: 'Geneva', HND: 'Tokyo', ICN: 'Seoul', INN: 'Innsbruck', JFK: 'New York',
|
||||||
|
KEF: 'Reykjavik', KKN: 'Kirkenes', KLR: 'Kalmar', KRK: 'Kraków', KRN: 'Kiruna', LAX: 'Los Angeles', LCA: 'Larnaca',
|
||||||
|
LHR: 'London', LIS: 'Lisbon', LPA: 'Gran Canaria', LYR: 'Svalbard', MIA: 'Miami', MLA: 'Malta', NCE: 'Nice',
|
||||||
|
OPO: 'Porto', OSD: 'Åre', PRG: 'Prague', RAK: 'Marrakech', RVN: 'Rovaniemi', SCR: 'Sälen', SFO: 'San Francisco',
|
||||||
|
SVQ: 'Seville', SZG: 'Salzburg', TFS: 'Tenerife', TOS: 'Tromsø', VBY: 'Visby', VCE: 'Venice', VIE: 'Vienna',
|
||||||
|
YYZ: 'Toronto',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iso = (y, m, d) => `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||||
|
const weekday = (d) => new Date(d + 'T00:00:00Z').getUTCDay(); // 0 = Sunday
|
||||||
|
const nextWeekday = (d, wd) => addDays(d, (wd - weekday(d) + 7) % 7);
|
||||||
|
|
||||||
|
function easterSunday(y) { // anonymous Gregorian algorithm
|
||||||
|
const a = y % 19, b = Math.floor(y / 100), c = y % 100, d = Math.floor(b / 4), e = b % 4;
|
||||||
|
const f = Math.floor((b + 8) / 25), g = Math.floor((b - f + 1) / 3), h = (19 * a + b - d - g + 15) % 30;
|
||||||
|
const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7, m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||||
|
return iso(y, Math.floor((h + l - 7 * m + 114) / 31), ((h + l - 7 * m + 114) % 31) + 1);
|
||||||
|
}
|
||||||
|
const isoWeekMonday = (y, week) => { // Monday of ISO week `week`
|
||||||
|
const jan4 = iso(y, 1, 4);
|
||||||
|
return addDays(jan4, -((weekday(jan4) + 6) % 7) + (week - 1) * 7);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** window(year, today, rnd) -> {from, to}; yearly themes try this year, then next. */
|
||||||
|
const THEMES = [
|
||||||
|
{ names: ['Christmas in {city}', 'Visiting Santa in {city}', 'Hiding from Christmas shopping in {city}', 'Mulled wine in {city}'],
|
||||||
|
places: ['RVN', 'TOS', 'KRN', 'KEF', 'LYR', 'VIE', 'SZG'], nights: [4, 9],
|
||||||
|
window: (y) => ({ from: iso(y, 12, 20), to: iso(y, 12, 30) }) },
|
||||||
|
{ names: ["New Year's Eve in {city}", 'Fireworks and regrets in {city}', 'Counting down in {city}'],
|
||||||
|
places: ['KEF', 'BER', 'LHR', 'EDI', 'PRG', 'BCN', 'JFK'], nights: [2, 4],
|
||||||
|
window: (y) => ({ from: iso(y, 12, 29), to: iso(y + 1, 1, 2) }) },
|
||||||
|
{ names: ['Getaway weekend in {city}', '48 hours in {city}', 'A very long lunch in {city}', 'Pretending to be a local in {city}'],
|
||||||
|
places: ['BCN', 'PRG', 'FCO', 'CDG', 'LHR', 'BER', 'AMS', 'LIS', 'BUD', 'KRK', 'VIE', 'EDI', 'DUB', 'VCE', 'FLR', 'OPO'],
|
||||||
|
nights: [2, 3],
|
||||||
|
window: (y, today, rnd) => { // a Friday 3-10 weeks from now, through Monday
|
||||||
|
const fri = addDays(nextWeekday(addDays(today, 21), 5), 7 * Math.floor(rnd() * 8));
|
||||||
|
return { from: fri, to: addDays(fri, 3) };
|
||||||
|
} },
|
||||||
|
{ names: ['Escaping the dark to {city}', 'Vitamin D hunt in {city}', 'Thawing out in {city}'],
|
||||||
|
places: ['TFS', 'LPA', 'FUE', 'FNC', 'AGP', 'AGA', 'RAK', 'MLA', 'LCA', 'MIA'], nights: [6, 12],
|
||||||
|
window: (y, today, rnd) => { const from = addDays(iso(y, 1, 10), Math.floor(rnd() * 50)); return { from, to: addDays(from, 14) }; } },
|
||||||
|
{ names: ['Ski week in {city}', 'Après-ski practice in {city}', 'Falling down mountains in {city}'],
|
||||||
|
places: ['INN', 'SZG', 'GVA', 'OSD', 'SCR'], nights: [5, 8],
|
||||||
|
window: (y) => { const mon = isoWeekMonday(y, 9); return { from: addDays(mon, -2), to: addDays(mon, 6) }; } }, // Stockholm sportlov
|
||||||
|
{ names: ["Valentine's escape to {city}", 'Romantic disaster in {city}', 'Candlelit dinner in {city}'],
|
||||||
|
places: ['CDG', 'VCE', 'FCO', 'LIS', 'FLR', 'NCE'], nights: [2, 3], seats: 2,
|
||||||
|
window: (y) => ({ from: iso(y, 2, 12), to: iso(y, 2, 16) }) },
|
||||||
|
{ names: ['Easter egg hunt in {city}', 'Easter witch flight to {city}', 'Chocolate eggs in {city}'],
|
||||||
|
places: ['FCO', 'FLR', 'SVQ', 'ATH', 'LIS', 'AGP'], nights: [3, 4],
|
||||||
|
window: (y) => { const sun = easterSunday(y); return { from: addDays(sun, -3), to: addDays(sun, 1) }; } },
|
||||||
|
{ names: ['Midsummer in {city}', 'Dancing like small frogs in {city}', 'Midnight sun in {city}'],
|
||||||
|
places: ['VBY', 'KLR', 'KRN', 'TOS', 'BOO', 'LYR'], nights: [2, 3],
|
||||||
|
window: (y) => { const eve = nextWeekday(iso(y, 6, 19), 5); return { from: addDays(eve, -1), to: addDays(eve, 2) }; } },
|
||||||
|
{ names: ['Bucket list: {city}', 'Finally going to {city}', 'Long-haul daydream: {city}'],
|
||||||
|
places: ['HND', 'ICN', 'LAX', 'SFO', 'EWR', 'MIA', 'YYZ'], nights: [10, 21], cabins: ['economy', 'premium', 'business'],
|
||||||
|
window: (y, today, rnd) => { const from = addDays(today, 60 + Math.floor(rnd() * 120)); return { from, to: addDays(from, 21) }; } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pick = (list, rnd) => list[Math.floor(rnd() * list.length)];
|
||||||
|
|
||||||
|
/** Next window of a theme that starts at least two weeks from today. */
|
||||||
|
function themeWindow(theme, today, rnd) {
|
||||||
|
const y = Number(today.slice(0, 4));
|
||||||
|
for (const year of [y, y + 1]) {
|
||||||
|
const w = theme.window(year, today, rnd);
|
||||||
|
if (w.from >= addDays(today, 14)) return w;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exampleWatch(theme, today, rnd = Math.random) {
|
||||||
|
const code = pick(theme.places, rnd);
|
||||||
|
const w = themeWindow(theme, today, rnd);
|
||||||
|
return {
|
||||||
|
name: pick(theme.names, rnd).replace('{city}', PLACES[code]),
|
||||||
|
enabled: true,
|
||||||
|
origins: ['ARN'],
|
||||||
|
destinations: [code],
|
||||||
|
from: w.from,
|
||||||
|
to: w.to,
|
||||||
|
cabins: theme.cabins || ['economy', 'premium'],
|
||||||
|
min_seats: theme.seats || 1,
|
||||||
|
max_stops: null,
|
||||||
|
require_both_legs: true,
|
||||||
|
min_nights: theme.nights[0],
|
||||||
|
max_nights: theme.nights[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A config with `count` themed watches, chosen among themes that come up within ~7 months. */
|
||||||
|
function exampleConfig(today = todayISO(), rnd = Math.random, count = 2) {
|
||||||
|
const soon = THEMES.map((t) => ({ t, w: themeWindow(t, today, () => 0) }))
|
||||||
|
.filter((x) => x.w && x.w.from <= addDays(today, 210)).map((x) => x.t);
|
||||||
|
const chosen = [];
|
||||||
|
while (chosen.length < count && soon.length) chosen.push(soon.splice(Math.floor(rnd() * soon.length), 1)[0]);
|
||||||
|
return { settings: { ...DEFAULT_CONFIG.settings }, notify: [], watches: chosen.map((t) => exampleWatch(t, today, rnd)) };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- config
|
// ---------------------------------------------------------------- config
|
||||||
|
|
||||||
const blank = (v) => v == null || v === '';
|
const blank = (v) => v == null || v === '';
|
||||||
@@ -164,9 +256,10 @@
|
|||||||
|
|
||||||
const notify = (raw.notify || []).filter(Boolean).map((n, i) => {
|
const notify = (raw.notify || []).filter(Boolean).map((n, i) => {
|
||||||
const type = String(n.type || '').toLowerCase();
|
const type = String(n.type || '').toLowerCase();
|
||||||
if (!['ntfy', 'discord', 'slack', 'webhook'].includes(type)) err(`notification ${i + 1}: unknown type "${n.type}"`);
|
if (type === 'ntfy') err(`notification ${i + 1}: ntfy is no longer supported, use zulip, slack, discord or webhook`);
|
||||||
|
else if (!NOTIFY_TYPES.includes(type)) err(`notification ${i + 1}: unknown type "${n.type}"`);
|
||||||
if (!/^https?:\/\//.test(n.url || '')) err(`notification ${i + 1}: URL must start with http(s)://`);
|
if (!/^https?:\/\//.test(n.url || '')) err(`notification ${i + 1}: URL must start with http(s)://`);
|
||||||
return { type, url: n.url, priority: n.priority };
|
return { type, url: n.url };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (errors.length) throw new Error(errors.join('\n'));
|
if (errors.length) throw new Error(errors.join('\n'));
|
||||||
@@ -443,7 +536,7 @@
|
|||||||
return { runCycle, estimate };
|
return { runCycle, estimate };
|
||||||
}
|
}
|
||||||
|
|
||||||
const core = { CABINS, validateConfig, migrateWatch, parseCalendar, parseFlight, createWatcher, legNeeds, applyRoundTrip, DEFAULT_CONFIG };
|
const core = { 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
|
||||||
@@ -464,7 +557,11 @@
|
|||||||
|
|
||||||
// -- config & state
|
// -- config & state
|
||||||
let cfg, cfgError = null;
|
let cfg, cfgError = null;
|
||||||
const rawConfig = gmGet('config', DEFAULT_CONFIG);
|
let rawConfig = gmGet('config', null);
|
||||||
|
if (!rawConfig) { // first run: start from a random themed example, saved so it doesn't change on every reload
|
||||||
|
rawConfig = exampleConfig();
|
||||||
|
gmSet('config', rawConfig);
|
||||||
|
}
|
||||||
try { cfg = validateConfig(rawConfig); } catch (e) { cfgError = e.message; }
|
try { cfg = validateConfig(rawConfig); } catch (e) { cfgError = e.message; }
|
||||||
const state = gmGet('state', {});
|
const state = gmGet('state', {});
|
||||||
const saveState = () => gmSet('state', state);
|
const saveState = () => gmSet('state', state);
|
||||||
@@ -480,16 +577,15 @@
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const NTFY_PRIORITY = { min: 1, low: 2, default: 3, high: 4, urgent: 5, max: 5 };
|
|
||||||
async function sendOne(n, title, body, data) {
|
async function sendOne(n, title, body, data) {
|
||||||
if (n.type === 'ntfy') {
|
if (n.type === 'zulip') {
|
||||||
const u = new URL(n.url); // https://ntfy.sh/topic -> publish JSON to the server root
|
// Zulip's Slack-compatible webhook (…/api/v1/external/slack_incoming?api_key=…&stream=…): Slack *bold* is
|
||||||
await gmPost(u.origin + '/', { topic: u.pathname.replace(/^\/+/, ''), title, message: body, tags: ['airplane'],
|
// converted; "channel" becomes the topic unless the URL sets &topic=
|
||||||
priority: NTFY_PRIORITY[n.priority] || +n.priority || 3 });
|
await gmPost(n.url, { text: `*${title}*\n${body}`, channel: data.watch || 'SAS bonus watch' });
|
||||||
} else if (n.type === 'discord') {
|
|
||||||
await gmPost(n.url, { content: `**${title}**\n${body}`.slice(0, 2000) });
|
|
||||||
} else if (n.type === 'slack') {
|
} else if (n.type === 'slack') {
|
||||||
await gmPost(n.url, { text: `*${title}*\n${body}` });
|
await gmPost(n.url, { text: `*${title}*\n${body}` });
|
||||||
|
} else if (n.type === 'discord') {
|
||||||
|
await gmPost(n.url, { content: `**${title}**\n${body}`.slice(0, 2000) });
|
||||||
} else {
|
} else {
|
||||||
await gmPost(n.url, { title, body, ...data });
|
await gmPost(n.url, { title, body, ...data });
|
||||||
}
|
}
|
||||||
@@ -644,7 +740,7 @@
|
|||||||
th { font-weight: 600; color: var(--muted); font-size: 12px; white-space: nowrap; }
|
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, 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[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; }
|
input.code { width: 8.5em; text-transform: uppercase; } input.name { width: 17em; } input.num { width: 4.2em; } input.url { width: 100%; min-width: 22em; }
|
||||||
label.cab { white-space: nowrap; margin-right: 6px; font-size: 13px; }
|
label.cab { white-space: nowrap; margin-right: 6px; font-size: 13px; }
|
||||||
.wlist { border-top: 1px solid var(--line); }
|
.wlist { border-top: 1px solid var(--line); }
|
||||||
.wrow { padding: 8px 4px; border-bottom: 1px solid var(--line); }
|
.wrow { padding: 8px 4px; border-bottom: 1px solid var(--line); }
|
||||||
@@ -778,7 +874,7 @@
|
|||||||
const normalizeDraft = (raw) => {
|
const normalizeDraft = (raw) => {
|
||||||
const d = clone(raw || {});
|
const d = clone(raw || {});
|
||||||
d.settings = { ...DEFAULT_CONFIG.settings, ...(d.settings || {}) };
|
d.settings = { ...DEFAULT_CONFIG.settings, ...(d.settings || {}) };
|
||||||
d.notify = (d.notify || []).map((n) => ({ type: 'ntfy', url: '', priority: '', ...n }));
|
d.notify = (d.notify || []).map(({ priority, ...n }) => ({ type: 'zulip', url: '', ...n }));
|
||||||
d.watches = (d.watches || []).map(migrateWatch).map((w) => ({
|
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,
|
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),
|
cabins: (w.cabins || Object.values(CABIN_KEYS)).map(cabinKey),
|
||||||
@@ -877,11 +973,9 @@
|
|||||||
|
|
||||||
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') }, ['ntfy', 'discord', 'slack', 'webhook'].map((t) =>
|
h('td', {}, h('select', { ...bind(n, 'type') }, NOTIFY_TYPES.map((t) =>
|
||||||
h('option', { value: t, selected: n.type === t }, 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') })),
|
h('td', { style: 'width:100%' }, h('input', { class: 'url', value: n.url || '', placeholder: 'Webhook URL', ...bind(n, 'url') })),
|
||||||
h('td', {}, h('select', { title: 'Priority (ntfy only)', ...bind(n, 'priority') }, ['', 'low', 'default', 'high', 'urgent'].map((p) =>
|
|
||||||
h('option', { value: p, selected: (n.priority || '') === p }, p || '–')))),
|
|
||||||
h('td', {}, removeBtn(draft.notify, n, 'notification')));
|
h('td', {}, removeBtn(draft.notify, n, 'notification')));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -904,10 +998,10 @@
|
|||||||
|
|
||||||
h('h3', {}, 'Notifications'),
|
h('h3', {}, 'Notifications'),
|
||||||
h('div', { class: 'scroll' }, h('table', {},
|
h('div', { class: 'scroll' }, h('table', {},
|
||||||
h('tr', {}, ['Type', 'URL', 'Priority', ''].map((c) => h('th', {}, c))),
|
h('tr', {}, ['Type', 'URL', ''].map((c) => h('th', {}, c))),
|
||||||
draft.notify.map(notifyRow))),
|
draft.notify.map(notifyRow))),
|
||||||
h('div', { class: 'btns' },
|
h('div', { class: 'btns' },
|
||||||
h('button', { onclick: () => { draft.notify.push({ type: 'ntfy', url: '', priority: 'high' }); renderEditor(); } },
|
h('button', { onclick: () => { draft.notify.push({ type: 'zulip', url: '' }); renderEditor(); } },
|
||||||
'+ Add notification'),
|
'+ Add notification'),
|
||||||
h('label', {}, h('input', { type: 'checkbox', checked: st.desktop_notifications, ...bind(st, 'desktop_notifications') }),
|
h('label', {}, h('input', { type: 'checkbox', checked: st.desktop_notifications, ...bind(st, 'desktop_notifications') }),
|
||||||
' Desktop notifications')),
|
' Desktop notifications')),
|
||||||
@@ -953,8 +1047,11 @@
|
|||||||
} }, 'Download .json'),
|
} }, 'Download .json'),
|
||||||
h('button', { onclick: () => file.click() }, 'Load file…'),
|
h('button', { onclick: () => file.click() }, 'Load file…'),
|
||||||
file,
|
file,
|
||||||
h('button', { onclick: () => { if (confirm('Replace the config with the example?')) { json.value = JSON.stringify(DEFAULT_CONFIG, null, 2); apply(); } } },
|
h('button', { onclick: () => {
|
||||||
'Reset to example')),
|
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);
|
msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user