signalk-chiplog 2.7.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -1
- package/README.md +152 -45
- package/docs/screenshots/05-stats.png +0 -0
- package/docs/screenshots/06-3d-animation.png +0 -0
- package/index.js +241 -4
- package/lib/api.js +123 -2
- package/lib/cloud-pairing.js +172 -0
- package/lib/cloud-restore.js +416 -0
- package/lib/cloud-sync.js +455 -0
- package/lib/database.js +50 -1
- package/lib/detection.js +4 -0
- package/lib/events.js +84 -20
- package/lib/export.js +1 -1
- package/lib/mail.js +162 -0
- package/lib/passage-map.js +342 -0
- package/lib/passage-summary.js +169 -0
- package/lib/places.js +6 -0
- package/lib/png.js +295 -0
- package/lib/qr.js +266 -0
- package/lib/smtp.js +242 -0
- package/lib/summary-mailer.js +286 -0
- package/package.json +2 -1
- package/public/app.css +94 -0
- package/public/entry/entry.css +94 -1
- package/public/entry/icons/apple-touch-icon.png +0 -0
- package/public/entry/icons/icon-192.png +0 -0
- package/public/entry/icons/icon-512.png +0 -0
- package/public/entry/index.html +2 -2
- package/public/entry/js/components/Dialogs.mjs +82 -16
- package/public/entry/js/components/Icons.mjs +33 -0
- package/public/entry/js/components/NotePanel.mjs +21 -3
- package/public/entry/js/components/RecentList.mjs +2 -3
- package/public/entry/js/journal.mjs +24 -7
- package/public/entry/js/main.mjs +27 -18
- package/public/entry/manifest.webmanifest +1 -1
- package/public/entry/sw.js +2 -0
- package/public/icon-small.svg +37 -0
- package/public/icon.svg +82 -6
- package/public/index.html +1 -1
- package/public/js/components/CloudBackup.mjs +298 -0
- package/public/js/components/ExportView.mjs +3 -0
- package/public/js/components/LogView.mjs +63 -6
- package/public/js/components/PassageView.mjs +4 -3
- package/public/js/components/Timeline.mjs +40 -14
- package/public/js/context.mjs +22 -3
- package/public/js/format.mjs +34 -0
- package/public/js/i18n.mjs +107 -2
- package/public/js/log-lines.mjs +5 -0
- package/public/js/main.mjs +1 -1
- package/public/js/view-memory.mjs +39 -0
package/index.js
CHANGED
|
@@ -10,6 +10,11 @@ const { OBSERVATION_DEFAULTS } = require('./lib/observation-recorder');
|
|
|
10
10
|
const { createPlaceNamer, GEOCODING_DEFAULTS } = require('./lib/place-names');
|
|
11
11
|
const { PROPULSION_DEFAULTS } = require('./lib/propulsion-detector');
|
|
12
12
|
const { createReplayJob } = require('./lib/replay-job');
|
|
13
|
+
const { createSummaryMailer, SUMMARY_MAIL_DEFAULTS } = require('./lib/summary-mailer');
|
|
14
|
+
const { createCloudSync, CLOUD_SYNC_DEFAULTS } = require('./lib/cloud-sync');
|
|
15
|
+
const { createCloudPairing } = require('./lib/cloud-pairing');
|
|
16
|
+
const { createCloudRestore } = require('./lib/cloud-restore');
|
|
17
|
+
const { SECURITIES } = require('./lib/smtp');
|
|
13
18
|
const { createTideForecaster, TIDE_DEFAULTS } = require('./lib/tide-forecaster');
|
|
14
19
|
const { createWeatherForecaster, WEATHER_DEFAULTS } = require('./lib/weather-forecaster');
|
|
15
20
|
const { createTrackRecorder, SAMPLE_INTERVAL_MS, TRACK_DEFAULTS } = require('./lib/track-recorder');
|
|
@@ -30,6 +35,12 @@ const FIRST_FORECAST_DELAY_MS = 5 * 1000;
|
|
|
30
35
|
// Landmarks are not needed for the passage under way, only for reading it back,
|
|
31
36
|
// so the first lookup waits for the busier start-up work to be done.
|
|
32
37
|
const FIRST_LANDMARK_DELAY_MS = 30 * 1000;
|
|
38
|
+
// Likewise: a summary is never urgent, and the passage it is about has been
|
|
39
|
+
// over for at least the reopening delay by the time it is due.
|
|
40
|
+
const FIRST_SUMMARY_MAIL_DELAY_MS = 60 * 1000;
|
|
41
|
+
// The first run after a start reads every passage to work out its hashes: not
|
|
42
|
+
// while detection and the other chains are starting up.
|
|
43
|
+
const FIRST_CLOUD_SYNC_DELAY_MS = 2 * 60 * 1000;
|
|
33
44
|
|
|
34
45
|
const MOTION_LABELS = { underway: 'Under way', stopped: 'Stopped', unknown: 'Waiting for data' };
|
|
35
46
|
const MODE_LABELS = { autostate: 'navigation.state', fallback: 'speed fallback' };
|
|
@@ -64,6 +75,13 @@ module.exports = function (app) {
|
|
|
64
75
|
// The open passage detection last reported, to notice a new one.
|
|
65
76
|
let lastActiveEntryId = null;
|
|
66
77
|
let usbExport = null;
|
|
78
|
+
let summaryMailer = null;
|
|
79
|
+
let cloudSync = null;
|
|
80
|
+
let cloudSchedule = null;
|
|
81
|
+
let cloudPairing = null;
|
|
82
|
+
let cloudRestore = null;
|
|
83
|
+
// The configuration as saved, so pairing can add the token to it without losing the rest.
|
|
84
|
+
let savedConfig = {};
|
|
67
85
|
let replayJob = null;
|
|
68
86
|
let namingTimer = null;
|
|
69
87
|
let timers = [];
|
|
@@ -195,19 +213,106 @@ module.exports = function (app) {
|
|
|
195
213
|
title: 'Copy to the USB drive at each arrival',
|
|
196
214
|
default: USB_EXPORT_DEFAULTS.usbExportOnArrival
|
|
197
215
|
},
|
|
216
|
+
summaryMailEnabled: {
|
|
217
|
+
type: 'boolean',
|
|
218
|
+
title: 'Email a summary of each passage',
|
|
219
|
+
description:
|
|
220
|
+
'Once a passage is over for good — past the delay within which a new departure would carry it on — sends its map and figures to the addresses below',
|
|
221
|
+
default: SUMMARY_MAIL_DEFAULTS.summaryMailEnabled
|
|
222
|
+
},
|
|
223
|
+
summaryMailTo: {
|
|
224
|
+
type: 'string',
|
|
225
|
+
title: 'Summary recipients',
|
|
226
|
+
description: 'One or more email addresses, separated by commas'
|
|
227
|
+
},
|
|
228
|
+
summaryMailFrom: {
|
|
229
|
+
type: 'string',
|
|
230
|
+
title: 'Summary sender',
|
|
231
|
+
description:
|
|
232
|
+
'The address the summaries are sent from, e.g. logbook@example.org. Most relays insist it be one they are allowed to send for'
|
|
233
|
+
},
|
|
234
|
+
smtpHost: {
|
|
235
|
+
type: 'string',
|
|
236
|
+
title: 'SMTP server',
|
|
237
|
+
description: 'The mail relay the summaries are handed to. Leave empty to turn them off'
|
|
238
|
+
},
|
|
239
|
+
smtpPort: {
|
|
240
|
+
type: 'number',
|
|
241
|
+
title: 'SMTP port',
|
|
242
|
+
description: '587 for submission with STARTTLS, 465 for implicit TLS, 25 for a local relay',
|
|
243
|
+
default: SUMMARY_MAIL_DEFAULTS.smtpPort,
|
|
244
|
+
minimum: 1
|
|
245
|
+
},
|
|
246
|
+
smtpSecurity: {
|
|
247
|
+
type: 'string',
|
|
248
|
+
title: 'SMTP security',
|
|
249
|
+
enum: SECURITIES,
|
|
250
|
+
enumNames: ['STARTTLS (port 587)', 'TLS (port 465)', 'None (local relay only)'],
|
|
251
|
+
default: SUMMARY_MAIL_DEFAULTS.smtpSecurity
|
|
252
|
+
},
|
|
253
|
+
smtpUsername: {
|
|
254
|
+
type: 'string',
|
|
255
|
+
title: 'SMTP user name',
|
|
256
|
+
description: 'Leave empty if the relay needs no authentication'
|
|
257
|
+
},
|
|
258
|
+
smtpPassword: {
|
|
259
|
+
type: 'string',
|
|
260
|
+
title: 'SMTP password',
|
|
261
|
+
format: 'password'
|
|
262
|
+
},
|
|
263
|
+
summaryMailTileUrl: {
|
|
264
|
+
type: 'string',
|
|
265
|
+
title: 'Map tiles for the summary',
|
|
266
|
+
description:
|
|
267
|
+
'The tiles the summary map is drawn over. The public OpenStreetMap instance by default (© OpenStreetMap contributors, ODbL), or a self-hosted one; empty draws the track on a plain background',
|
|
268
|
+
default: SUMMARY_MAIL_DEFAULTS.summaryMailTileUrl
|
|
269
|
+
},
|
|
270
|
+
cloudSyncEnabled: {
|
|
271
|
+
type: 'boolean',
|
|
272
|
+
title: 'Back the logbook up online',
|
|
273
|
+
description:
|
|
274
|
+
'Sends each passage, with its track, events and readings, to the online service below as soon as there is a connection: new and changed passages, newest first, and the removal of those deleted here. Nothing is sent while this is off',
|
|
275
|
+
default: CLOUD_SYNC_DEFAULTS.cloudSyncEnabled
|
|
276
|
+
},
|
|
277
|
+
cloudSyncUrl: {
|
|
278
|
+
type: 'string',
|
|
279
|
+
title: 'Online service address',
|
|
280
|
+
description: 'The address of the Ti Bord service: https://app.tibord.com'
|
|
281
|
+
},
|
|
282
|
+
cloudSyncToken: {
|
|
283
|
+
type: 'string',
|
|
284
|
+
title: 'Device token',
|
|
285
|
+
description: 'The token the service gave this boat',
|
|
286
|
+
format: 'password'
|
|
287
|
+
},
|
|
288
|
+
cloudSyncIntervalMinutes: {
|
|
289
|
+
type: 'number',
|
|
290
|
+
title: 'Online backup interval (minutes)',
|
|
291
|
+
description:
|
|
292
|
+
'How often to check for passages to send; each departure and arrival is sent straight away',
|
|
293
|
+
default: CLOUD_SYNC_DEFAULTS.cloudSyncIntervalMinutes,
|
|
294
|
+
minimum: 1
|
|
295
|
+
},
|
|
296
|
+
cloudSyncCostlyLink: {
|
|
297
|
+
type: 'boolean',
|
|
298
|
+
title: 'Costly link (satellite, roaming)',
|
|
299
|
+
description:
|
|
300
|
+
'Sends only a summary of each passage and a thinned-out track, a few kilobytes, at departures and arrivals and every six hours. Full tracks, instrument readings, forecasts and places wait until this is turned off, or until Back up now is chosen on the Export page',
|
|
301
|
+
default: CLOUD_SYNC_DEFAULTS.cloudSyncCostlyLink
|
|
302
|
+
},
|
|
198
303
|
logbookLanguage: {
|
|
199
304
|
type: 'string',
|
|
200
|
-
title: 'Logbook language (PDF)',
|
|
305
|
+
title: 'Logbook language (PDF and summary emails)',
|
|
201
306
|
description:
|
|
202
|
-
'Language of the PDF logbook written to the USB drive; downloads from the webapp use the webapp language',
|
|
307
|
+
'Language of the PDF logbook written to the USB drive and of the passage summary emails; downloads from the webapp use the webapp language',
|
|
203
308
|
enum: PDF_LANGUAGES,
|
|
204
309
|
default: 'en'
|
|
205
310
|
},
|
|
206
311
|
logbookTimeZone: {
|
|
207
312
|
type: 'string',
|
|
208
|
-
title: 'Ship’s time zone (PDF)',
|
|
313
|
+
title: 'Ship’s time zone (PDF and summary emails)',
|
|
209
314
|
description:
|
|
210
|
-
'IANA time zone the PDF logbook on the USB drive
|
|
315
|
+
'IANA time zone the PDF logbook on the USB drive and the passage summary emails are kept in, e.g. Europe/Paris. Empty uses the server’s time zone; downloads from the webapp use the browser’s'
|
|
211
316
|
},
|
|
212
317
|
windSpeedThresholds: {
|
|
213
318
|
type: 'array',
|
|
@@ -367,6 +472,9 @@ module.exports = function (app) {
|
|
|
367
472
|
// retry delay left from an earlier failure.
|
|
368
473
|
if (outcome.activeEntryId !== null && outcome.activeEntryId !== lastActiveEntryId) {
|
|
369
474
|
schedules.forEach((schedule) => schedule.nudge());
|
|
475
|
+
} else if (outcome.activeEntryId !== lastActiveEntryId) {
|
|
476
|
+
// An arrival: back the finished passage up while the boat is in reach.
|
|
477
|
+
cloudSchedule?.nudge();
|
|
370
478
|
}
|
|
371
479
|
lastActiveEntryId = outcome.activeEntryId;
|
|
372
480
|
const usbError = usbExport.status().lastError;
|
|
@@ -440,7 +548,57 @@ module.exports = function (app) {
|
|
|
440
548
|
version
|
|
441
549
|
});
|
|
442
550
|
|
|
551
|
+
// Saves what pairing brought into the plugin configuration, as if typed in the admin, and
|
|
552
|
+
// starts using it at once: the backup's settings are read on every run, so no restart.
|
|
553
|
+
function savePairing(url, token) {
|
|
554
|
+
const next = {
|
|
555
|
+
...savedConfig,
|
|
556
|
+
cloudSyncEnabled: true,
|
|
557
|
+
cloudSyncUrl: url,
|
|
558
|
+
cloudSyncToken: token
|
|
559
|
+
};
|
|
560
|
+
return new Promise((resolve, reject) => {
|
|
561
|
+
app.savePluginOptions(next, (err) => {
|
|
562
|
+
if (err) {
|
|
563
|
+
reject(err);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
savedConfig = next;
|
|
567
|
+
if (settings) {
|
|
568
|
+
settings.cloudSyncEnabled = true;
|
|
569
|
+
settings.cloudSyncUrl = url;
|
|
570
|
+
settings.cloudSyncToken = token;
|
|
571
|
+
}
|
|
572
|
+
cloudSchedule?.nudge();
|
|
573
|
+
resolve();
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Declares the link costly or cheap from the Export page, saved like the rest of the
|
|
579
|
+
// configuration. Back to cheap, a run starts at once: full passages have been waiting.
|
|
580
|
+
function saveCostlyLink(costly) {
|
|
581
|
+
const next = { ...savedConfig, cloudSyncCostlyLink: costly };
|
|
582
|
+
return new Promise((resolve, reject) => {
|
|
583
|
+
app.savePluginOptions(next, (err) => {
|
|
584
|
+
if (err) {
|
|
585
|
+
reject(err);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
savedConfig = next;
|
|
589
|
+
if (settings) {
|
|
590
|
+
settings.cloudSyncCostlyLink = costly;
|
|
591
|
+
}
|
|
592
|
+
if (!costly) {
|
|
593
|
+
cloudSchedule?.nudge();
|
|
594
|
+
}
|
|
595
|
+
resolve();
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
443
600
|
plugin.start = function (config = {}) {
|
|
601
|
+
savedConfig = config;
|
|
444
602
|
try {
|
|
445
603
|
const { db, migrated } = openDatabase(app.getDataDirPath());
|
|
446
604
|
database = db;
|
|
@@ -465,6 +623,26 @@ module.exports = function (app) {
|
|
|
465
623
|
usbExportIntervalMinutes:
|
|
466
624
|
config.usbExportIntervalMinutes ?? USB_EXPORT_DEFAULTS.usbExportIntervalMinutes,
|
|
467
625
|
usbExportOnArrival: config.usbExportOnArrival ?? USB_EXPORT_DEFAULTS.usbExportOnArrival,
|
|
626
|
+
summaryMailEnabled: config.summaryMailEnabled ?? SUMMARY_MAIL_DEFAULTS.summaryMailEnabled,
|
|
627
|
+
summaryMailTo: config.summaryMailTo || null,
|
|
628
|
+
summaryMailFrom: config.summaryMailFrom || null,
|
|
629
|
+
smtpHost: config.smtpHost || null,
|
|
630
|
+
smtpPort: config.smtpPort ?? SUMMARY_MAIL_DEFAULTS.smtpPort,
|
|
631
|
+
smtpSecurity: SECURITIES.includes(config.smtpSecurity)
|
|
632
|
+
? config.smtpSecurity
|
|
633
|
+
: SUMMARY_MAIL_DEFAULTS.smtpSecurity,
|
|
634
|
+
smtpUsername: config.smtpUsername || null,
|
|
635
|
+
smtpPassword: config.smtpPassword || null,
|
|
636
|
+
summaryMailTileUrl:
|
|
637
|
+
config.summaryMailTileUrl === undefined
|
|
638
|
+
? SUMMARY_MAIL_DEFAULTS.summaryMailTileUrl
|
|
639
|
+
: config.summaryMailTileUrl,
|
|
640
|
+
cloudSyncEnabled: config.cloudSyncEnabled ?? CLOUD_SYNC_DEFAULTS.cloudSyncEnabled,
|
|
641
|
+
cloudSyncUrl: config.cloudSyncUrl || null,
|
|
642
|
+
cloudSyncToken: config.cloudSyncToken || null,
|
|
643
|
+
cloudSyncIntervalMinutes:
|
|
644
|
+
config.cloudSyncIntervalMinutes ?? CLOUD_SYNC_DEFAULTS.cloudSyncIntervalMinutes,
|
|
645
|
+
cloudSyncCostlyLink: config.cloudSyncCostlyLink ?? CLOUD_SYNC_DEFAULTS.cloudSyncCostlyLink,
|
|
468
646
|
logbookLanguage: PDF_LANGUAGES.includes(config.logbookLanguage)
|
|
469
647
|
? config.logbookLanguage
|
|
470
648
|
: 'en',
|
|
@@ -542,8 +720,46 @@ module.exports = function (app) {
|
|
|
542
720
|
// sailed through, each on its own chain rather than inside detection.
|
|
543
721
|
const networkOptions = { db: database, settings, userAgent: `signalk-chiplog/${version}` };
|
|
544
722
|
const log = (level, message) => (level === 'error' ? app.error(message) : app.debug(message));
|
|
723
|
+
summaryMailer = createSummaryMailer({
|
|
724
|
+
db: database,
|
|
725
|
+
settings,
|
|
726
|
+
userAgent: `signalk-chiplog/${version}`,
|
|
727
|
+
vesselName: () => readVesselName(app),
|
|
728
|
+
log
|
|
729
|
+
});
|
|
730
|
+
cloudSync = createCloudSync({
|
|
731
|
+
db: database,
|
|
732
|
+
settings,
|
|
733
|
+
userAgent: `signalk-chiplog/${version}`,
|
|
734
|
+
log
|
|
735
|
+
});
|
|
736
|
+
// Nudged at departures and arrivals, apart from the forecasts which only
|
|
737
|
+
// care about departures.
|
|
738
|
+
cloudSchedule = createBackgroundSchedule({
|
|
739
|
+
label: 'Online backup',
|
|
740
|
+
resolver: cloudSync,
|
|
741
|
+
log,
|
|
742
|
+
firstDelayMs: FIRST_CLOUD_SYNC_DELAY_MS
|
|
743
|
+
});
|
|
744
|
+
cloudPairing = createCloudPairing({
|
|
745
|
+
db: database,
|
|
746
|
+
userAgent: `signalk-chiplog/${version}`,
|
|
747
|
+
log,
|
|
748
|
+
onPaired: ({ url, token }) => savePairing(url, token)
|
|
749
|
+
});
|
|
750
|
+
cloudRestore = createCloudRestore({
|
|
751
|
+
db: database,
|
|
752
|
+
settings,
|
|
753
|
+
userAgent: `signalk-chiplog/${version}`,
|
|
754
|
+
log,
|
|
755
|
+
// The backup held off while passages were coming back: it has places to send, at least.
|
|
756
|
+
onDone: () => cloudSchedule?.nudge()
|
|
757
|
+
});
|
|
758
|
+
// A restore the last run left unfinished carries on by itself.
|
|
759
|
+
cloudRestore.resume();
|
|
545
760
|
lastActiveEntryId = null;
|
|
546
761
|
schedules = [
|
|
762
|
+
cloudSchedule,
|
|
547
763
|
createBackgroundSchedule({
|
|
548
764
|
label: 'Tide forecast',
|
|
549
765
|
resolver: createTideForecaster(networkOptions),
|
|
@@ -561,6 +777,14 @@ module.exports = function (app) {
|
|
|
561
777
|
resolver: createLandmarkFinder(networkOptions),
|
|
562
778
|
log,
|
|
563
779
|
firstDelayMs: FIRST_LANDMARK_DELAY_MS
|
|
780
|
+
}),
|
|
781
|
+
// Nothing nudges this one: a passage becomes due a summary by growing
|
|
782
|
+
// old enough, not by anything detection reports (SPEC §4.16).
|
|
783
|
+
createBackgroundSchedule({
|
|
784
|
+
label: 'Passage summary email',
|
|
785
|
+
resolver: summaryMailer,
|
|
786
|
+
log,
|
|
787
|
+
firstDelayMs: FIRST_SUMMARY_MAIL_DELAY_MS
|
|
564
788
|
})
|
|
565
789
|
];
|
|
566
790
|
lastStatus = null;
|
|
@@ -600,6 +824,13 @@ module.exports = function (app) {
|
|
|
600
824
|
schedules = [];
|
|
601
825
|
usbExport?.stop();
|
|
602
826
|
usbExport = null;
|
|
827
|
+
summaryMailer = null;
|
|
828
|
+
cloudSync = null;
|
|
829
|
+
cloudSchedule = null;
|
|
830
|
+
cloudPairing?.stop();
|
|
831
|
+
cloudPairing = null;
|
|
832
|
+
cloudRestore?.stop();
|
|
833
|
+
cloudRestore = null;
|
|
603
834
|
replayJob?.cancel();
|
|
604
835
|
replayJob = null;
|
|
605
836
|
detector = null;
|
|
@@ -629,6 +860,12 @@ module.exports = function (app) {
|
|
|
629
860
|
observeEvent: (entryId, time) => detector.observeEvent(entryId, time),
|
|
630
861
|
noteDeparture: (entryId) => detector.noteDeparture(entryId),
|
|
631
862
|
usbExport,
|
|
863
|
+
summaryMailer,
|
|
864
|
+
cloudSync,
|
|
865
|
+
nudgeCloudSync: () => cloudSchedule?.nudge(),
|
|
866
|
+
saveCostlyLink,
|
|
867
|
+
cloudPairing,
|
|
868
|
+
cloudRestore,
|
|
632
869
|
replayJob,
|
|
633
870
|
pdfOptions,
|
|
634
871
|
detection: () => ({
|
package/lib/api.js
CHANGED
|
@@ -499,7 +499,7 @@ function registerRoutes(router, { getContext, logError }) {
|
|
|
499
499
|
|
|
500
500
|
readwrite.patch(
|
|
501
501
|
'/api/events/:id',
|
|
502
|
-
handle(({ db }, req) => {
|
|
502
|
+
handle(({ db, now }, req) => {
|
|
503
503
|
const id = v.parseId(req.params.id);
|
|
504
504
|
const body = v.requireBody(req.body, ['time', 'comment', 'subtype', 'payload']);
|
|
505
505
|
const patch = withoutUndefined({
|
|
@@ -508,7 +508,7 @@ function registerRoutes(router, { getContext, logError }) {
|
|
|
508
508
|
subtype: optional(body, 'subtype', nullableString(200)),
|
|
509
509
|
payload: optional(body, 'payload', parsePayload)
|
|
510
510
|
});
|
|
511
|
-
return events.updateEvent(db, id, patch);
|
|
511
|
+
return events.updateEvent(db, id, patch, { now: now() });
|
|
512
512
|
})
|
|
513
513
|
);
|
|
514
514
|
|
|
@@ -719,6 +719,127 @@ function registerRoutes(router, { getContext, logError }) {
|
|
|
719
719
|
})
|
|
720
720
|
);
|
|
721
721
|
|
|
722
|
+
readonly.get(
|
|
723
|
+
'/api/summary-mail',
|
|
724
|
+
handle(({ summaryMailer }) => summaryMailer.status())
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
// Sends one passage's summary now, whether or not it already went out: how
|
|
728
|
+
// the SMTP settings are tried without waiting for the next arrival.
|
|
729
|
+
admin.post(
|
|
730
|
+
'/api/entries/:id/summary-mail',
|
|
731
|
+
handle(async ({ db, summaryMailer }, req) => {
|
|
732
|
+
const id = entryId(req);
|
|
733
|
+
entries.requireEntryRow(db, id);
|
|
734
|
+
try {
|
|
735
|
+
return await summaryMailer.send(id);
|
|
736
|
+
} catch (err) {
|
|
737
|
+
throw conflict('summary_mail_failed', err.message);
|
|
738
|
+
}
|
|
739
|
+
})
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
readonly.get(
|
|
743
|
+
'/api/cloud-sync',
|
|
744
|
+
handle(({ cloudSync, cloudPairing, cloudRestore }) => ({
|
|
745
|
+
...cloudSync.status(),
|
|
746
|
+
pairing: cloudPairing.status(),
|
|
747
|
+
restore: cloudRestore.status()
|
|
748
|
+
}))
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
// The earlier logbooks of this boat the service can give back, asked of the service now.
|
|
752
|
+
readonly.get(
|
|
753
|
+
'/api/cloud-sync/restore',
|
|
754
|
+
handle(async ({ cloudRestore }) => {
|
|
755
|
+
try {
|
|
756
|
+
return { ...cloudRestore.status(), logbooks: await cloudRestore.candidates() };
|
|
757
|
+
} catch (err) {
|
|
758
|
+
throw conflict('cloud_restore_failed', err.message);
|
|
759
|
+
}
|
|
760
|
+
})
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
// Starts bringing one of them back into this logbook, which must be empty; the restore
|
|
764
|
+
// goes on in the background and GET /cloud-sync follows it.
|
|
765
|
+
admin.post(
|
|
766
|
+
'/api/cloud-sync/restore',
|
|
767
|
+
handle(({ cloudRestore }, req) => {
|
|
768
|
+
const logbookId = req.body?.logbookId;
|
|
769
|
+
if (typeof logbookId !== 'string' || !/^[0-9a-f-]{36}$/i.test(logbookId)) {
|
|
770
|
+
throw badRequest('logbookId is required');
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
cloudRestore.start(logbookId.toLowerCase());
|
|
774
|
+
} catch (err) {
|
|
775
|
+
throw conflict(err.code ?? 'cloud_restore_failed', err.message);
|
|
776
|
+
}
|
|
777
|
+
return cloudRestore.status();
|
|
778
|
+
})
|
|
779
|
+
);
|
|
780
|
+
|
|
781
|
+
// Asks the service for a pairing code, then waits in the background for someone to claim
|
|
782
|
+
// it; GET /cloud-sync follows the pairing.
|
|
783
|
+
admin.post(
|
|
784
|
+
'/api/cloud-sync/pairing',
|
|
785
|
+
handle(async ({ cloudPairing }, req) => {
|
|
786
|
+
if (typeof req.body?.url !== 'string' || req.body.url.trim() === '') {
|
|
787
|
+
throw badRequest('url is required');
|
|
788
|
+
}
|
|
789
|
+
try {
|
|
790
|
+
return await cloudPairing.start(req.body.url.trim());
|
|
791
|
+
} catch (err) {
|
|
792
|
+
if (err.code === 'invalid_url') {
|
|
793
|
+
throw badRequest(err.message);
|
|
794
|
+
}
|
|
795
|
+
throw conflict('cloud_pairing_failed', err.message);
|
|
796
|
+
}
|
|
797
|
+
})
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
admin.delete(
|
|
801
|
+
'/api/cloud-sync/pairing',
|
|
802
|
+
handle(({ cloudPairing }) => {
|
|
803
|
+
cloudPairing.cancel();
|
|
804
|
+
return cloudPairing.status();
|
|
805
|
+
})
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
// Starts a backup run now rather than at the next interval; the run itself
|
|
809
|
+
// goes on in the background, through its schedule, never alongside another.
|
|
810
|
+
admin.post(
|
|
811
|
+
'/api/cloud-sync',
|
|
812
|
+
handle(({ cloudSync, nudgeCloudSync }) => {
|
|
813
|
+
const status = cloudSync.status();
|
|
814
|
+
if (!status.enabled || !status.configured) {
|
|
815
|
+
throw conflict(
|
|
816
|
+
'cloud_sync_not_configured',
|
|
817
|
+
`Turn the online backup on and fill in its settings first${status.problem ? `: ${status.problem}` : ''}`
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
// Asked for by hand, the run sends everything in full, costly link or not.
|
|
821
|
+
cloudSync.requestFull();
|
|
822
|
+
nudgeCloudSync();
|
|
823
|
+
return status;
|
|
824
|
+
})
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
// Declares the link costly, or cheap again (SPEC §4.17).
|
|
828
|
+
admin.post(
|
|
829
|
+
'/api/cloud-sync/costly-link',
|
|
830
|
+
handle(async ({ cloudSync, saveCostlyLink }, req) => {
|
|
831
|
+
if (typeof req.body?.costly !== 'boolean') {
|
|
832
|
+
throw badRequest('costly must be true or false');
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
await saveCostlyLink(req.body.costly);
|
|
836
|
+
} catch (err) {
|
|
837
|
+
throw conflict('cloud_sync_save_failed', err.message);
|
|
838
|
+
}
|
|
839
|
+
return cloudSync.status();
|
|
840
|
+
})
|
|
841
|
+
);
|
|
842
|
+
|
|
722
843
|
readonly.get(
|
|
723
844
|
'/api/replay',
|
|
724
845
|
handle(({ replayJob }) => replayJob.status())
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const { getLogbookId } = require('./cloud-sync');
|
|
2
|
+
const { qrCode } = require('./qr');
|
|
3
|
+
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 30 * 1000;
|
|
5
|
+
// When the service is out of reach mid-pairing: keep asking, as long as the code lives.
|
|
6
|
+
const RETRY_MS = 10 * 1000;
|
|
7
|
+
|
|
8
|
+
class PairingError extends Error {
|
|
9
|
+
constructor(message, { status = null, code = null } = {}) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function serviceUrl(text) {
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(text);
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return url.toString().replace(/\/+$/, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pairs the plugin with the online service by a short code instead of a pasted token
|
|
30
|
+
// (SPEC §4.17): the service hands out a code, someone claims it for their boat, and the plugin,
|
|
31
|
+
// polling meanwhile with a secret only it holds, collects its device token. `onPaired` is
|
|
32
|
+
// given the service address and the token, to save them in the plugin configuration.
|
|
33
|
+
function createCloudPairing({
|
|
34
|
+
db,
|
|
35
|
+
userAgent,
|
|
36
|
+
onPaired,
|
|
37
|
+
log,
|
|
38
|
+
fetch = globalThis.fetch,
|
|
39
|
+
clock = Date.now
|
|
40
|
+
}) {
|
|
41
|
+
let state = { state: 'idle' };
|
|
42
|
+
// The current attempt's secret and address, never reported.
|
|
43
|
+
let attempt = null;
|
|
44
|
+
let timer = null;
|
|
45
|
+
|
|
46
|
+
async function call(url, path, body) {
|
|
47
|
+
const response = await fetch(`${url}/v1${path}`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
'content-type': 'application/json',
|
|
51
|
+
'user-agent': userAgent,
|
|
52
|
+
'x-chiplog-version': userAgent.split('/')[1] ?? ''
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify(body),
|
|
55
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
56
|
+
});
|
|
57
|
+
const data = await response.json().catch(() => ({}));
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new PairingError(data.message ?? `The service answered ${response.status}`, {
|
|
60
|
+
status: response.status,
|
|
61
|
+
code: data.error ?? null
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function schedule(current, delayMs) {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
timer = setTimeout(() => poll(current), delayMs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function poll(current) {
|
|
73
|
+
if (attempt !== current) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
let result;
|
|
77
|
+
try {
|
|
78
|
+
result = await call(current.url, `/pairings/${current.pairingId}/poll`, {
|
|
79
|
+
pollSecret: current.pollSecret
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (attempt !== current) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (error.status === 410 || Date.parse(state.expiresAt) <= clock()) {
|
|
86
|
+
attempt = null;
|
|
87
|
+
state = { state: 'expired', at: new Date(clock()).toISOString() };
|
|
88
|
+
} else if (!(error instanceof PairingError)) {
|
|
89
|
+
// Out of reach for a moment: the code is still good.
|
|
90
|
+
schedule(current, RETRY_MS);
|
|
91
|
+
} else {
|
|
92
|
+
attempt = null;
|
|
93
|
+
state = { state: 'failed', error: { message: error.message, code: error.code } };
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (attempt !== current) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (result.status !== 'paired') {
|
|
101
|
+
schedule(current, current.pollIntervalMs);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
attempt = null;
|
|
105
|
+
try {
|
|
106
|
+
await onPaired({ url: current.url, token: result.token });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
log('error', `Online backup paired, but its settings could not be saved: ${error.message}`);
|
|
109
|
+
state = { state: 'failed', error: { message: error.message, code: 'save_failed' } };
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
state = {
|
|
113
|
+
state: 'paired',
|
|
114
|
+
vesselName: result.vesselName,
|
|
115
|
+
at: new Date(clock()).toISOString()
|
|
116
|
+
};
|
|
117
|
+
log('info', `Online backup paired with ${result.vesselName || 'a vessel'} at ${current.url}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
// Asks the service for a code and starts waiting for it to be claimed. A pairing already
|
|
122
|
+
// under way is dropped: its code was not used.
|
|
123
|
+
async start(text) {
|
|
124
|
+
const url = serviceUrl(text);
|
|
125
|
+
if (!url) {
|
|
126
|
+
throw new PairingError('The service address must be an http or https URL', {
|
|
127
|
+
code: 'invalid_url'
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
this.cancel();
|
|
131
|
+
const pairing = await call(url, '/pairings', { logbookId: getLogbookId(db) });
|
|
132
|
+
const current = {
|
|
133
|
+
url,
|
|
134
|
+
pairingId: pairing.pairingId,
|
|
135
|
+
pollSecret: pairing.pollSecret,
|
|
136
|
+
pollIntervalMs: Math.max(1, pairing.pollIntervalSeconds) * 1000
|
|
137
|
+
};
|
|
138
|
+
attempt = current;
|
|
139
|
+
state = {
|
|
140
|
+
state: 'waiting',
|
|
141
|
+
url,
|
|
142
|
+
code: pairing.code,
|
|
143
|
+
claimUrl: pairing.claimUrl ?? null,
|
|
144
|
+
// The same address as a QR code, for a phone to open: the plugin's page is often on
|
|
145
|
+
// the chart table, the account on the phone in someone's hand.
|
|
146
|
+
claimQr: pairing.claimUrl ? qrCode(pairing.claimUrl) : null,
|
|
147
|
+
expiresAt: pairing.expiresAt
|
|
148
|
+
};
|
|
149
|
+
schedule(current, current.pollIntervalMs);
|
|
150
|
+
return this.status();
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
cancel() {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
attempt = null;
|
|
156
|
+
if (state.state === 'waiting') {
|
|
157
|
+
state = { state: 'idle' };
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
status() {
|
|
162
|
+
return { ...state };
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
stop() {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
attempt = null;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { createCloudPairing, PairingError };
|