signalk-chiplog 2.11.0 → 2.13.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 +31 -1
- package/README.md +20 -5
- package/index.js +31 -2
- package/lib/api.js +24 -4
- package/lib/cloud-pairing.js +27 -13
- package/lib/cloud-restore.js +13 -8
- package/lib/cloud-sync.js +14 -6
- package/lib/network-error.js +72 -0
- package/lib/plotter-extension.js +59 -0
- package/package.json +2 -1
- package/public/app.css +0 -6
- package/public/entry/entry.css +26 -0
- package/public/entry/js/components/AccessGate.mjs +1 -1
- package/public/entry/js/components/CrewDialog.mjs +2 -1
- package/public/entry/js/components/StatusHeader.mjs +1 -1
- package/public/entry/js/confirm.mjs +42 -0
- package/public/entry/js/main.mjs +43 -2
- package/public/entry/js/plotter.mjs +30 -0
- package/public/entry/sw.js +4 -1
- package/public/js/components/CloudBackup.mjs +14 -18
- package/public/js/i18n.mjs +10 -6
- package/public/vendor/plotterext-LICENSE +21 -0
- package/public/vendor/plotterext.mjs +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,34 @@ All notable changes to Chiplog are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.13.0] - 2026-10-24
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Remove the pairing** from the Export page, after a confirmation: the plugin forgets its token and the backup stops.
|
|
14
|
+
What is already on the service stays there.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Online backup says why the service cannot be reached.** Instead of "fetch failed", pairing, backup and restore name
|
|
19
|
+
the host and the cause (name not resolved, connection refused, certificate refused, no answer in time), with a hint
|
|
20
|
+
for the common ones — a wrong clock behind a certificate refused. A pairing that cannot start is written to the server
|
|
21
|
+
log.
|
|
22
|
+
- **Pairing no longer asks for the service address.** It is `https://app.tibord.com` for a new plugin; another one, for
|
|
23
|
+
testing or development, is set in the plugin configuration.
|
|
24
|
+
|
|
25
|
+
## [2.12.0] - 2026-10-24
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **Helm entry beside the chart.** Chartplotters supporting the Plotter Extensions API can open Chiplog from a toolbar
|
|
30
|
+
button. The iframe panel keeps drafts while hidden and follows the plotter's night mode when available. The standalone
|
|
31
|
+
tablet app and its offline queue remain available.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Event and crew deletion confirmations work inside sandboxed plotter panels.
|
|
36
|
+
|
|
9
37
|
## [2.11.0] - 2026-10-24
|
|
10
38
|
|
|
11
39
|
### Added
|
|
@@ -483,7 +511,9 @@ First release.
|
|
|
483
511
|
- REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
|
|
484
512
|
- Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
|
|
485
513
|
|
|
486
|
-
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.
|
|
514
|
+
[Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.13.0...HEAD
|
|
515
|
+
[2.13.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.12.0...v2.13.0
|
|
516
|
+
[2.12.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.11.0...v2.12.0
|
|
487
517
|
[2.11.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.10.0...v2.11.0
|
|
488
518
|
[2.10.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.9.0...v2.10.0
|
|
489
519
|
[2.9.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.8.0...v2.9.0
|
package/README.md
CHANGED
|
@@ -437,6 +437,18 @@ Logging needs read/write access. The first time, the app shows **This tablet nee
|
|
|
437
437
|
To revoke it, delete the device under **Security → Devices**: the tablet asks for access again. **Sign in instead** uses
|
|
438
438
|
a regular Signal K user account.
|
|
439
439
|
|
|
440
|
+
### From a chartplotter
|
|
441
|
+
|
|
442
|
+
In a chartplotter supporting the Signal K Plotter Extensions API (such as Freeboard-SK), the **Chiplog** toolbar button
|
|
443
|
+
opens the helm entry app in an iframe panel beside the chart. Closing and reopening it keeps notes and handwriting in
|
|
444
|
+
progress; reloading the plotter still discards unsent drafts. The usual offline queue and Signal K access rights apply.
|
|
445
|
+
The panel follows the plotter's night mode when available, and its night switch also changes the plotter's mode.
|
|
446
|
+
Standalone tablet use remains available at the same address.
|
|
447
|
+
|
|
448
|
+
The extension is advertised automatically while the plugin is enabled. A server without the resource-provider API can
|
|
449
|
+
still run the standalone apps. Journal and login navigation links are hidden in the extension: sign in through the
|
|
450
|
+
chartplotter, or use the panel's device access request.
|
|
451
|
+
|
|
440
452
|
## Configuration 🔧
|
|
441
453
|
|
|
442
454
|
In the Signal K admin, **Apps & Plugins → Configuration → Chiplog**.
|
|
@@ -525,14 +537,17 @@ Keep a copy of the logbook off the boat, on the Ti Bord online service. What it
|
|
|
525
537
|
and how you can share your passages and voyages from it, is at [tibord.com](https://tibord.com), which the **Online
|
|
526
538
|
backup** card links to as well.
|
|
527
539
|
|
|
528
|
-
1. On the **Export** page, under **Online backup**,
|
|
540
|
+
1. On the **Export** page, under **Online backup**, choose **Get a pairing code**.
|
|
529
541
|
2. On the service, signed in to your account, enter the code shown (e.g. `K7QF-3MXB`) and choose your boat — or scan the
|
|
530
542
|
QR code beside it with your phone, which opens the service with the code filled in. The code is valid for 15 minutes.
|
|
531
|
-
3. The page soon says the boat is paired: Chiplog has saved
|
|
532
|
-
|
|
543
|
+
3. The page soon says the boat is paired: Chiplog has saved its own token in the plugin configuration and turned the
|
|
544
|
+
backup on.
|
|
545
|
+
|
|
546
|
+
You can also paste a token by hand: turn **Back the logbook up online** on in the plugin configuration and fill in the
|
|
547
|
+
**Device token**. The **Online service address** is `https://app.tibord.com`; change it only to test another instance.
|
|
533
548
|
|
|
534
|
-
|
|
535
|
-
|
|
549
|
+
**Remove the pairing**, on the same card, asks for a confirmation, then forgets the token and stops the backup. What is
|
|
550
|
+
already on the service stays there; revoke the device from your account on the service to stop it for good.
|
|
536
551
|
|
|
537
552
|
From then on, whenever the boat has a connection, Chiplog sends:
|
|
538
553
|
|
package/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const { INFLUX_DEFAULTS } = require('./lib/influx-history');
|
|
|
8
8
|
const { createLandmarkFinder, LANDMARK_DEFAULTS } = require('./lib/landmark-finder');
|
|
9
9
|
const { OBSERVATION_DEFAULTS } = require('./lib/observation-recorder');
|
|
10
10
|
const { createPlaceNamer, GEOCODING_DEFAULTS } = require('./lib/place-names');
|
|
11
|
+
const { registerPlotterExtension } = require('./lib/plotter-extension');
|
|
11
12
|
const { PROPULSION_DEFAULTS } = require('./lib/propulsion-detector');
|
|
12
13
|
const { createReplayJob } = require('./lib/replay-job');
|
|
13
14
|
const { createSummaryMailer, SUMMARY_MAIL_DEFAULTS } = require('./lib/summary-mailer');
|
|
@@ -277,7 +278,9 @@ module.exports = function (app) {
|
|
|
277
278
|
cloudSyncUrl: {
|
|
278
279
|
type: 'string',
|
|
279
280
|
title: 'Online service address',
|
|
280
|
-
description:
|
|
281
|
+
description:
|
|
282
|
+
'The address of the Ti Bord service. Leave it as it is unless you test another instance',
|
|
283
|
+
default: CLOUD_SYNC_DEFAULTS.cloudSyncUrl
|
|
281
284
|
},
|
|
282
285
|
cloudSyncToken: {
|
|
283
286
|
type: 'string',
|
|
@@ -590,6 +593,28 @@ module.exports = function (app) {
|
|
|
590
593
|
});
|
|
591
594
|
}
|
|
592
595
|
|
|
596
|
+
// Forgets the pairing from the Export page: the token leaves the plugin configuration and the
|
|
597
|
+
// backup turns off; the service address stays. What the service holds stays there.
|
|
598
|
+
function removePairing() {
|
|
599
|
+
const next = { ...savedConfig, cloudSyncEnabled: false };
|
|
600
|
+
delete next.cloudSyncToken;
|
|
601
|
+
return new Promise((resolve, reject) => {
|
|
602
|
+
app.savePluginOptions(next, (err) => {
|
|
603
|
+
if (err) {
|
|
604
|
+
reject(err);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
savedConfig = next;
|
|
608
|
+
if (settings) {
|
|
609
|
+
settings.cloudSyncEnabled = false;
|
|
610
|
+
settings.cloudSyncToken = null;
|
|
611
|
+
}
|
|
612
|
+
cloudPairing?.cancel();
|
|
613
|
+
resolve();
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
593
618
|
// Declares the link costly or cheap from the Export page, saved like the rest of the
|
|
594
619
|
// configuration. Back to cheap, a run starts at once: full passages have been waiting.
|
|
595
620
|
function saveCostlyLink(costly) {
|
|
@@ -653,7 +678,7 @@ module.exports = function (app) {
|
|
|
653
678
|
? SUMMARY_MAIL_DEFAULTS.summaryMailTileUrl
|
|
654
679
|
: config.summaryMailTileUrl,
|
|
655
680
|
cloudSyncEnabled: config.cloudSyncEnabled ?? CLOUD_SYNC_DEFAULTS.cloudSyncEnabled,
|
|
656
|
-
cloudSyncUrl: config.cloudSyncUrl ||
|
|
681
|
+
cloudSyncUrl: config.cloudSyncUrl || CLOUD_SYNC_DEFAULTS.cloudSyncUrl,
|
|
657
682
|
cloudSyncToken: config.cloudSyncToken || null,
|
|
658
683
|
cloudSyncIntervalMinutes:
|
|
659
684
|
config.cloudSyncIntervalMinutes ?? CLOUD_SYNC_DEFAULTS.cloudSyncIntervalMinutes,
|
|
@@ -706,6 +731,8 @@ module.exports = function (app) {
|
|
|
706
731
|
throw err;
|
|
707
732
|
}
|
|
708
733
|
|
|
734
|
+
registerPlotterExtension(app, () => database !== null);
|
|
735
|
+
|
|
709
736
|
const readSelfPath = (path) => app.getSelfPath(path);
|
|
710
737
|
detector = createPassageDetector({ db: database, readSelfPath, settings });
|
|
711
738
|
const recorder = createTrackRecorder({ db: database, readSelfPath, settings });
|
|
@@ -762,6 +789,7 @@ module.exports = function (app) {
|
|
|
762
789
|
});
|
|
763
790
|
cloudPairing = createCloudPairing({
|
|
764
791
|
db: database,
|
|
792
|
+
serviceUrl: () => settings.cloudSyncUrl,
|
|
765
793
|
userAgent: `signalk-chiplog/${version}`,
|
|
766
794
|
log,
|
|
767
795
|
onPaired: ({ url, token }) => savePairing(url, token)
|
|
@@ -883,6 +911,7 @@ module.exports = function (app) {
|
|
|
883
911
|
cloudSync,
|
|
884
912
|
nudgeCloudSync: () => cloudSchedule?.nudge(),
|
|
885
913
|
saveCostlyLink,
|
|
914
|
+
removePairing,
|
|
886
915
|
cloudPairing,
|
|
887
916
|
cloudRestore,
|
|
888
917
|
replayJob,
|
package/lib/api.js
CHANGED
|
@@ -778,16 +778,36 @@ function registerRoutes(router, { getContext, logError }) {
|
|
|
778
778
|
})
|
|
779
779
|
);
|
|
780
780
|
|
|
781
|
+
// Forgets the pairing: the device token is removed from the plugin configuration and the
|
|
782
|
+
// backup turns off. The service keeps what it holds, and the device until revoked there.
|
|
783
|
+
admin.delete(
|
|
784
|
+
'/api/cloud-sync/token',
|
|
785
|
+
handle(async ({ cloudSync, cloudPairing, cloudRestore, removePairing }) => {
|
|
786
|
+
try {
|
|
787
|
+
await removePairing();
|
|
788
|
+
} catch (err) {
|
|
789
|
+
throw conflict('cloud_sync_save_failed', err.message);
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
...cloudSync.status(),
|
|
793
|
+
pairing: cloudPairing.status(),
|
|
794
|
+
restore: cloudRestore.status()
|
|
795
|
+
};
|
|
796
|
+
})
|
|
797
|
+
);
|
|
798
|
+
|
|
781
799
|
// Asks the service for a pairing code, then waits in the background for someone to claim
|
|
782
|
-
// it; GET /cloud-sync follows the pairing.
|
|
800
|
+
// it; GET /cloud-sync follows the pairing. The service is the configured one unless `url`
|
|
801
|
+
// names another.
|
|
783
802
|
admin.post(
|
|
784
803
|
'/api/cloud-sync/pairing',
|
|
785
804
|
handle(async ({ cloudPairing }, req) => {
|
|
786
|
-
|
|
787
|
-
|
|
805
|
+
const url = req.body?.url;
|
|
806
|
+
if (url !== undefined && (typeof url !== 'string' || url.trim() === '')) {
|
|
807
|
+
throw badRequest('url must be a non-empty string');
|
|
788
808
|
}
|
|
789
809
|
try {
|
|
790
|
-
return await cloudPairing.start(
|
|
810
|
+
return await cloudPairing.start(url?.trim());
|
|
791
811
|
} catch (err) {
|
|
792
812
|
if (err.code === 'invalid_url') {
|
|
793
813
|
throw badRequest(err.message);
|
package/lib/cloud-pairing.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { getLogbookId } = require('./cloud-sync');
|
|
2
2
|
const { qrCode } = require('./qr');
|
|
3
|
+
const { fetchExplained } = require('./network-error');
|
|
3
4
|
|
|
4
5
|
const REQUEST_TIMEOUT_MS = 30 * 1000;
|
|
5
6
|
// When the service is out of reach mid-pairing: keep asking, as long as the code lives.
|
|
@@ -32,6 +33,8 @@ function serviceUrl(text) {
|
|
|
32
33
|
// given the service address and the token, to save them in the plugin configuration.
|
|
33
34
|
function createCloudPairing({
|
|
34
35
|
db,
|
|
36
|
+
// The address to pair with when none is given: the one in the plugin configuration.
|
|
37
|
+
serviceUrl: configuredUrl = () => null,
|
|
35
38
|
userAgent,
|
|
36
39
|
onPaired,
|
|
37
40
|
log,
|
|
@@ -44,16 +47,20 @@ function createCloudPairing({
|
|
|
44
47
|
let timer = null;
|
|
45
48
|
|
|
46
49
|
async function call(url, path, body) {
|
|
47
|
-
const response = await
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
'
|
|
52
|
-
|
|
50
|
+
const response = await fetchExplained(
|
|
51
|
+
fetch,
|
|
52
|
+
`${url}/v1${path}`,
|
|
53
|
+
{
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
'content-type': 'application/json',
|
|
57
|
+
'user-agent': userAgent,
|
|
58
|
+
'x-chiplog-version': userAgent.split('/')[1] ?? ''
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify(body)
|
|
53
61
|
},
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
});
|
|
62
|
+
REQUEST_TIMEOUT_MS
|
|
63
|
+
);
|
|
57
64
|
const data = await response.json().catch(() => ({}));
|
|
58
65
|
if (!response.ok) {
|
|
59
66
|
throw new PairingError(data.message ?? `The service answered ${response.status}`, {
|
|
@@ -87,6 +94,7 @@ function createCloudPairing({
|
|
|
87
94
|
state = { state: 'expired', at: new Date(clock()).toISOString() };
|
|
88
95
|
} else if (!(error instanceof PairingError)) {
|
|
89
96
|
// Out of reach for a moment: the code is still good.
|
|
97
|
+
log('debug', `Online backup pairing: ${error.message}; retrying`);
|
|
90
98
|
schedule(current, RETRY_MS);
|
|
91
99
|
} else {
|
|
92
100
|
attempt = null;
|
|
@@ -119,16 +127,22 @@ function createCloudPairing({
|
|
|
119
127
|
|
|
120
128
|
return {
|
|
121
129
|
// 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);
|
|
130
|
+
// under way is dropped: its code was not used. Without an address, the configured one.
|
|
131
|
+
async start(text = configuredUrl()) {
|
|
132
|
+
const url = serviceUrl(text ?? '');
|
|
125
133
|
if (!url) {
|
|
126
134
|
throw new PairingError('The service address must be an http or https URL', {
|
|
127
135
|
code: 'invalid_url'
|
|
128
136
|
});
|
|
129
137
|
}
|
|
130
138
|
this.cancel();
|
|
131
|
-
|
|
139
|
+
let pairing;
|
|
140
|
+
try {
|
|
141
|
+
pairing = await call(url, '/pairings', { logbookId: getLogbookId(db) });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
log('error', `Online backup pairing could not start: ${error.message}`);
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
132
146
|
const current = {
|
|
133
147
|
url,
|
|
134
148
|
pairingId: pairing.pairingId,
|
package/lib/cloud-restore.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { withTransaction } = require('./database');
|
|
2
2
|
const { getLogbookId, isRestoring } = require('./cloud-sync');
|
|
3
3
|
const { TRACK_READINGS, OBSERVATION_READINGS } = require('./passage-import');
|
|
4
|
+
const { fetchExplained } = require('./network-error');
|
|
4
5
|
|
|
5
6
|
const REQUEST_TIMEOUT_MS = 2 * 60 * 1000;
|
|
6
7
|
|
|
@@ -250,15 +251,19 @@ function createCloudRestore({
|
|
|
250
251
|
const isEmpty = () => db.prepare('SELECT count(*) AS n FROM log_entries').get().n === 0;
|
|
251
252
|
|
|
252
253
|
async function call(path) {
|
|
253
|
-
const response = await
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
'
|
|
258
|
-
|
|
254
|
+
const response = await fetchExplained(
|
|
255
|
+
fetch,
|
|
256
|
+
`${settings.cloudSyncUrl.replace(/\/+$/, '')}/v1${path}`,
|
|
257
|
+
{
|
|
258
|
+
method: 'GET',
|
|
259
|
+
headers: {
|
|
260
|
+
authorization: `Bearer ${settings.cloudSyncToken}`,
|
|
261
|
+
'user-agent': userAgent,
|
|
262
|
+
'x-chiplog-version': userAgent.split('/')[1] ?? ''
|
|
263
|
+
}
|
|
259
264
|
},
|
|
260
|
-
|
|
261
|
-
|
|
265
|
+
REQUEST_TIMEOUT_MS
|
|
266
|
+
);
|
|
262
267
|
if (!response.ok) {
|
|
263
268
|
let detail = {};
|
|
264
269
|
try {
|
package/lib/cloud-sync.js
CHANGED
|
@@ -9,9 +9,13 @@ const { allSegments } = require('./propulsion');
|
|
|
9
9
|
const { getTideForecast } = require('./tide-forecaster');
|
|
10
10
|
const { allObservations, allTrackPoints } = require('./track');
|
|
11
11
|
const { getWeatherForecast } = require('./weather-forecaster');
|
|
12
|
+
const { fetchExplained } = require('./network-error');
|
|
12
13
|
|
|
13
14
|
const CLOUD_SYNC_DEFAULTS = {
|
|
14
15
|
cloudSyncEnabled: false,
|
|
16
|
+
// Where every boat backs up; another address is for testing and development, set in the
|
|
17
|
+
// plugin configuration.
|
|
18
|
+
cloudSyncUrl: 'https://app.tibord.com',
|
|
15
19
|
cloudSyncIntervalMinutes: 15,
|
|
16
20
|
cloudSyncCostlyLink: false
|
|
17
21
|
};
|
|
@@ -180,12 +184,16 @@ function createCloudSync({
|
|
|
180
184
|
headers['content-encoding'] = 'gzip';
|
|
181
185
|
payload = zlib.gzipSync(JSON.stringify(body));
|
|
182
186
|
}
|
|
183
|
-
const response = await
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
const response = await fetchExplained(
|
|
188
|
+
fetch,
|
|
189
|
+
`${base()}${path}`,
|
|
190
|
+
{
|
|
191
|
+
method,
|
|
192
|
+
headers,
|
|
193
|
+
body: payload
|
|
194
|
+
},
|
|
195
|
+
REQUEST_TIMEOUT_MS
|
|
196
|
+
);
|
|
189
197
|
if (!response.ok) {
|
|
190
198
|
let detail = {};
|
|
191
199
|
try {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Node's fetch says only "fetch failed" when no answer came back; the reason — a name that does
|
|
2
|
+
// not resolve, a refused connection, a certificate refused — is in `cause`, which the plugin
|
|
3
|
+
// would otherwise lose. These turn such a failure into one sentence for the log and the page.
|
|
4
|
+
|
|
5
|
+
// A hint for the causes a boat meets most, where the fix is not obvious from the code alone.
|
|
6
|
+
const HINTS = [
|
|
7
|
+
[/^(ENOTFOUND|EAI_AGAIN)$/, 'check the address and that this server can resolve names (DNS)'],
|
|
8
|
+
[
|
|
9
|
+
/^(CERT_|UNABLE_TO_VERIFY|SELF_SIGNED|DEPTH_ZERO|ERR_TLS_CERT)/,
|
|
10
|
+
"check this server's clock: a wrong date makes valid certificates look invalid"
|
|
11
|
+
],
|
|
12
|
+
[/^(ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ENETUNREACH)$/, 'check the address and the network']
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function describeCause(cause) {
|
|
16
|
+
if (!cause) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
// Several addresses tried (IPv4 and IPv6): each has its own reason.
|
|
20
|
+
if (Array.isArray(cause.errors) && cause.errors.length > 0) {
|
|
21
|
+
const parts = [
|
|
22
|
+
...new Set(cause.errors.map((each) => describeCause(each)?.text).filter(Boolean))
|
|
23
|
+
];
|
|
24
|
+
return { code: cause.code ?? cause.errors[0].code ?? null, text: parts.join('; ') };
|
|
25
|
+
}
|
|
26
|
+
const message = cause.message || String(cause);
|
|
27
|
+
const code = cause.code ?? null;
|
|
28
|
+
const text = code && !message.includes(code) ? `${code}: ${message}` : message;
|
|
29
|
+
return { code, text };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The error a failed `fetch(url)` threw, explained. An answer the service gave is not this
|
|
33
|
+
// function's: only failures where no answer came back are rewritten; anything else is returned
|
|
34
|
+
// as it was.
|
|
35
|
+
function networkError(error, url, timeoutMs) {
|
|
36
|
+
let host;
|
|
37
|
+
try {
|
|
38
|
+
host = new URL(url).host;
|
|
39
|
+
} catch {
|
|
40
|
+
host = url;
|
|
41
|
+
}
|
|
42
|
+
if (error?.name === 'TimeoutError') {
|
|
43
|
+
const result = new Error(
|
|
44
|
+
`No answer from ${host} within ${Math.round(timeoutMs / 1000)} s: check the network`
|
|
45
|
+
);
|
|
46
|
+
result.code = 'timeout';
|
|
47
|
+
result.cause = error;
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
if (!(error instanceof TypeError) || error.message !== 'fetch failed') {
|
|
51
|
+
return error;
|
|
52
|
+
}
|
|
53
|
+
const cause = describeCause(error.cause);
|
|
54
|
+
const hint = cause?.code && HINTS.find(([pattern]) => pattern.test(cause.code))?.[1];
|
|
55
|
+
const result = new Error(
|
|
56
|
+
`Cannot reach ${host}${cause ? ` (${cause.text})` : ''}${hint ? `: ${hint}` : ''}`
|
|
57
|
+
);
|
|
58
|
+
result.code = cause?.code ?? 'fetch_failed';
|
|
59
|
+
result.cause = error;
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// `fetch`, with what went wrong said when no answer came back.
|
|
64
|
+
async function fetchExplained(fetch, url, options, timeoutMs) {
|
|
65
|
+
try {
|
|
66
|
+
return await fetch(url, { ...options, signal: AbortSignal.timeout(timeoutMs) });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw networkError(error, url, timeoutMs);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { networkError, fetchExplained };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { version } = require('../package.json');
|
|
2
|
+
|
|
3
|
+
const EXTENSION_ID = 'signalk-chiplog';
|
|
4
|
+
|
|
5
|
+
function buildManifest() {
|
|
6
|
+
return {
|
|
7
|
+
name: 'Chiplog — Helm entry',
|
|
8
|
+
description: 'Log manoeuvres, notes and handwriting without leaving the chart.',
|
|
9
|
+
version,
|
|
10
|
+
apiVersion: '1',
|
|
11
|
+
requires: ['panels.iframe', 'buttons'],
|
|
12
|
+
optional: ['nightMode'],
|
|
13
|
+
panels: [
|
|
14
|
+
{
|
|
15
|
+
id: 'helm-entry',
|
|
16
|
+
title: 'Chiplog',
|
|
17
|
+
type: 'iframe',
|
|
18
|
+
url: '/signalk-chiplog/entry/?plotterExtension=1',
|
|
19
|
+
lifecycle: 'keepAlive'
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
buttons: [
|
|
23
|
+
{
|
|
24
|
+
id: 'open-helm-entry',
|
|
25
|
+
title: 'Chiplog',
|
|
26
|
+
slot: 'mapToolbar',
|
|
27
|
+
icon: 'edit_note',
|
|
28
|
+
action: { type: 'togglePanel', panel: 'helm-entry' }
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function registerPlotterExtension(app, isRunning) {
|
|
35
|
+
// Older servers can still run the standalone logbook and tablet app.
|
|
36
|
+
if (typeof app.registerResourceProvider !== 'function') {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
app.registerResourceProvider({
|
|
40
|
+
type: 'plotterExtensions',
|
|
41
|
+
methods: {
|
|
42
|
+
listResources: async () => (isRunning() ? { [EXTENSION_ID]: buildManifest() } : {}),
|
|
43
|
+
getResource: async (id) => {
|
|
44
|
+
if (!isRunning() || id !== EXTENSION_ID) {
|
|
45
|
+
throw new Error(`No such plotterExtensions resource: ${id}`);
|
|
46
|
+
}
|
|
47
|
+
return buildManifest();
|
|
48
|
+
},
|
|
49
|
+
setResource: async () => {
|
|
50
|
+
throw new Error('Chiplog plotter extension is read-only');
|
|
51
|
+
},
|
|
52
|
+
deleteResource: async () => {
|
|
53
|
+
throw new Error('Chiplog plotter extension is read-only');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { registerPlotterExtension };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-chiplog",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"mediabunny": "1.58.0",
|
|
82
82
|
"preact": "^10.29.8",
|
|
83
83
|
"prettier": "^3.9.6",
|
|
84
|
+
"signalk-plotterext-bus": "^0.21.0",
|
|
84
85
|
"three": "0.186.0"
|
|
85
86
|
}
|
|
86
87
|
}
|
package/public/app.css
CHANGED
|
@@ -1153,12 +1153,6 @@ button.preset-on {
|
|
|
1153
1153
|
margin-bottom: 1rem;
|
|
1154
1154
|
}
|
|
1155
1155
|
|
|
1156
|
-
.pairing-form label {
|
|
1157
|
-
display: grid;
|
|
1158
|
-
gap: 0.25rem;
|
|
1159
|
-
flex: 1 1 16rem;
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
1156
|
.pairing-code .code {
|
|
1163
1157
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1164
1158
|
font-size: 2rem;
|
package/public/entry/entry.css
CHANGED
|
@@ -749,6 +749,32 @@ input {
|
|
|
749
749
|
border-radius: var(--radius);
|
|
750
750
|
}
|
|
751
751
|
|
|
752
|
+
.confirmation-dialog {
|
|
753
|
+
width: min(40rem, calc(100% - 2rem));
|
|
754
|
+
max-height: calc(100% - 2rem);
|
|
755
|
+
padding: 0;
|
|
756
|
+
border: 0;
|
|
757
|
+
border-radius: var(--radius);
|
|
758
|
+
background: var(--surface);
|
|
759
|
+
color: var(--text);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
.confirmation-dialog::backdrop {
|
|
763
|
+
background: rgb(0 0 0 / 55%);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.confirmation-dialog .sheet {
|
|
767
|
+
width: 100%;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
:root[data-plotter-extension='true'] [data-standalone-link] {
|
|
771
|
+
display: none;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
:root[data-plotter-extension='true'] .entry-main {
|
|
775
|
+
padding: 0.75rem 0.75rem 9rem;
|
|
776
|
+
}
|
|
777
|
+
|
|
752
778
|
.sail-grid {
|
|
753
779
|
display: grid;
|
|
754
780
|
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
|
|
@@ -63,7 +63,7 @@ export function AccessGate({ onGranted }) {
|
|
|
63
63
|
${t('access.request')}
|
|
64
64
|
</button>`
|
|
65
65
|
}
|
|
66
|
-
<a class="big-button" href="/admin/#/login">${t('access.signIn')}</a>
|
|
66
|
+
<a class="big-button" data-standalone-link href="/admin/#/login">${t('access.signIn')}</a>
|
|
67
67
|
</div>
|
|
68
68
|
</section>
|
|
69
69
|
`;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { html, useState } from '../../../vendor/preact-htm.mjs';
|
|
2
2
|
import { useLocale } from '../../../js/context.mjs';
|
|
3
3
|
import { PencilIcon, TrashIcon } from './Icons.mjs';
|
|
4
|
+
import { confirmAction } from '../confirm.mjs';
|
|
4
5
|
|
|
5
6
|
// .big-button is a row flex container, so a bare <br/> between its children
|
|
6
7
|
// would not stack them -- wrap name and role in their own column instead.
|
|
@@ -83,7 +84,7 @@ export function CrewDialog({
|
|
|
83
84
|
};
|
|
84
85
|
|
|
85
86
|
const removeMember = async (member) => {
|
|
86
|
-
if (!
|
|
87
|
+
if (!(await confirmAction(t('entry.deleteCrewMemberConfirm', { name: member.name }), t))) {
|
|
87
88
|
return;
|
|
88
89
|
}
|
|
89
90
|
const removed = await onDeleteMember(member.id);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Native confirm() is blocked in plotter-extension sandboxes. An HTML dialog
|
|
2
|
+
// works there, traps focus, supports Escape, and keeps the same confirmation
|
|
3
|
+
// in the standalone app. Text is never interpreted as HTML.
|
|
4
|
+
export function confirmAction(message, t) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const dialog = document.createElement('dialog');
|
|
7
|
+
dialog.className = 'confirmation-dialog';
|
|
8
|
+
dialog.setAttribute('aria-label', message);
|
|
9
|
+
const form = document.createElement('form');
|
|
10
|
+
form.method = 'dialog';
|
|
11
|
+
form.className = 'sheet';
|
|
12
|
+
const text = document.createElement('p');
|
|
13
|
+
text.textContent = message;
|
|
14
|
+
const actions = document.createElement('div');
|
|
15
|
+
actions.className = 'sheet-row';
|
|
16
|
+
for (const [value, label, className] of [
|
|
17
|
+
['cancel', t('common.cancel'), 'big-button'],
|
|
18
|
+
['confirm', t('common.delete'), 'big-button danger']
|
|
19
|
+
]) {
|
|
20
|
+
const button = document.createElement('button');
|
|
21
|
+
button.type = 'submit';
|
|
22
|
+
button.value = value;
|
|
23
|
+
button.textContent = label;
|
|
24
|
+
button.className = className;
|
|
25
|
+
button.autofocus = value === 'cancel';
|
|
26
|
+
actions.append(button);
|
|
27
|
+
}
|
|
28
|
+
form.append(text, actions);
|
|
29
|
+
dialog.append(form);
|
|
30
|
+
dialog.addEventListener(
|
|
31
|
+
'close',
|
|
32
|
+
() => {
|
|
33
|
+
const confirmed = dialog.returnValue === 'confirm';
|
|
34
|
+
dialog.remove();
|
|
35
|
+
resolve(confirmed);
|
|
36
|
+
},
|
|
37
|
+
{ once: true }
|
|
38
|
+
);
|
|
39
|
+
document.body.append(dialog);
|
|
40
|
+
dialog.showModal();
|
|
41
|
+
});
|
|
42
|
+
}
|
package/public/entry/js/main.mjs
CHANGED
|
@@ -12,6 +12,8 @@ import { randomId } from '../../js/ids.mjs';
|
|
|
12
12
|
import { pickLanguage } from '../../js/i18n.mjs';
|
|
13
13
|
import { createServerClock } from './clock.mjs';
|
|
14
14
|
import { createJournal } from './journal.mjs';
|
|
15
|
+
import { confirmAction } from './confirm.mjs';
|
|
16
|
+
import { connectPlotter, isPlotterExtension } from './plotter.mjs';
|
|
15
17
|
import { AccessGate } from './components/AccessGate.mjs';
|
|
16
18
|
import { EventDialog, Toast } from './components/Dialogs.mjs';
|
|
17
19
|
import { CrewDialog } from './components/CrewDialog.mjs';
|
|
@@ -106,6 +108,7 @@ function App({ journal }) {
|
|
|
106
108
|
const [managingCrew, setManagingCrew] = useState(false);
|
|
107
109
|
const [tab, setTab] = useState('note');
|
|
108
110
|
const [night, setNight] = useState(readSetting(NIGHT_KEY) === 'on');
|
|
111
|
+
const [plotter, setPlotter] = useState(null);
|
|
109
112
|
const [version, setVersion] = useState(0);
|
|
110
113
|
const [busy, setBusy] = useState(false);
|
|
111
114
|
|
|
@@ -163,6 +166,43 @@ function App({ journal }) {
|
|
|
163
166
|
writeSetting(NIGHT_KEY, night ? 'on' : 'off');
|
|
164
167
|
}, [night]);
|
|
165
168
|
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!isPlotterExtension) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
let active = true;
|
|
174
|
+
let connection;
|
|
175
|
+
connectPlotter((enabled) => active && setNight(enabled))
|
|
176
|
+
.then((client) => {
|
|
177
|
+
if (!active) {
|
|
178
|
+
client.close();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
connection = client;
|
|
182
|
+
setPlotter(client);
|
|
183
|
+
})
|
|
184
|
+
// The tablet remains usable if the host cannot complete the handshake.
|
|
185
|
+
.catch(() => {});
|
|
186
|
+
return () => {
|
|
187
|
+
active = false;
|
|
188
|
+
connection?.close();
|
|
189
|
+
};
|
|
190
|
+
}, []);
|
|
191
|
+
|
|
192
|
+
const toggleNight = async () => {
|
|
193
|
+
const enabled = !night;
|
|
194
|
+
if (plotter?.hasCapability('nightMode')) {
|
|
195
|
+
try {
|
|
196
|
+
await plotter.call('nightMode.set', { enabled, auto: false });
|
|
197
|
+
setNight(enabled);
|
|
198
|
+
return;
|
|
199
|
+
} catch {
|
|
200
|
+
// Keep the local night switch usable if the host stops answering.
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
setNight(enabled);
|
|
204
|
+
};
|
|
205
|
+
|
|
166
206
|
useEffect(() => {
|
|
167
207
|
if (!toast || toast.kind === 'error') {
|
|
168
208
|
return undefined;
|
|
@@ -307,7 +347,7 @@ function App({ journal }) {
|
|
|
307
347
|
};
|
|
308
348
|
|
|
309
349
|
const deleteEvent = async (event) => {
|
|
310
|
-
if (!
|
|
350
|
+
if (!(await confirmAction(t('entry.deleteConfirm'), t))) {
|
|
311
351
|
return;
|
|
312
352
|
}
|
|
313
353
|
try {
|
|
@@ -376,7 +416,7 @@ function App({ journal }) {
|
|
|
376
416
|
onReconnect=${reconnect}
|
|
377
417
|
queued=${queue.pending.length}
|
|
378
418
|
night=${night}
|
|
379
|
-
onToggleNight=${
|
|
419
|
+
onToggleNight=${toggleNight}
|
|
380
420
|
/>
|
|
381
421
|
<main class="entry-main">
|
|
382
422
|
${
|
|
@@ -498,6 +538,7 @@ if ('serviceWorker' in navigator && window.isSecureContext) {
|
|
|
498
538
|
}
|
|
499
539
|
|
|
500
540
|
const locale = createLocale(pickLanguage(navigator.languages, location.search));
|
|
541
|
+
document.documentElement.dataset.plotterExtension = String(isPlotterExtension);
|
|
501
542
|
document.documentElement.lang = locale.language;
|
|
502
543
|
document.title = locale.t('entry.title');
|
|
503
544
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { connectExtension, windowPort } from '../../vendor/plotterext.mjs';
|
|
2
|
+
|
|
3
|
+
// An explicit opt-in leaves standalone and ordinary iframe use unchanged.
|
|
4
|
+
export const isPlotterExtension =
|
|
5
|
+
window.parent !== window && new URLSearchParams(location.search).get('plotterExtension') === '1';
|
|
6
|
+
|
|
7
|
+
export async function connectPlotter(onNightChange) {
|
|
8
|
+
const client = await connectExtension({
|
|
9
|
+
port: windowPort(window.parent, { origin: location.origin }),
|
|
10
|
+
timeoutMs: 5000
|
|
11
|
+
});
|
|
12
|
+
try {
|
|
13
|
+
if (client.hasCapability('nightMode')) {
|
|
14
|
+
// Subscribe first so a change during the initial read cannot be lost.
|
|
15
|
+
let changed = false;
|
|
16
|
+
await client.subscribe(['nightMode.changed'], (_name, state) => {
|
|
17
|
+
changed = true;
|
|
18
|
+
onNightChange(state.enabled);
|
|
19
|
+
});
|
|
20
|
+
const state = await client.call('nightMode.get');
|
|
21
|
+
if (!changed) {
|
|
22
|
+
onNightChange(state.enabled);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return client;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
client.close();
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/public/entry/sw.js
CHANGED
|
@@ -13,6 +13,8 @@ const SHELL = [
|
|
|
13
13
|
'icons/icon-512.png',
|
|
14
14
|
'icons/apple-touch-icon.png',
|
|
15
15
|
'js/main.mjs',
|
|
16
|
+
'js/confirm.mjs',
|
|
17
|
+
'js/plotter.mjs',
|
|
16
18
|
'js/access.mjs',
|
|
17
19
|
'js/clock.mjs',
|
|
18
20
|
'js/journal.mjs',
|
|
@@ -43,7 +45,8 @@ const SHELL = [
|
|
|
43
45
|
'../js/view-memory.mjs',
|
|
44
46
|
'../js/components/common.mjs',
|
|
45
47
|
'../js/components/Timeline.mjs',
|
|
46
|
-
'../vendor/preact-htm.mjs'
|
|
48
|
+
'../vendor/preact-htm.mjs',
|
|
49
|
+
'../vendor/plotterext.mjs'
|
|
47
50
|
];
|
|
48
51
|
|
|
49
52
|
const shellUrls = () => new Set(SHELL.map((file) => new URL(file, self.registration.scope).href));
|
|
@@ -105,27 +105,16 @@ function PairingState({ pairing, onCancel }) {
|
|
|
105
105
|
return null;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
function PairingForm({
|
|
108
|
+
function PairingForm({ onStart, busy }) {
|
|
109
109
|
const { t } = useLocale();
|
|
110
|
-
const [url, setUrl] = useState(initialUrl ?? '');
|
|
111
110
|
return html`
|
|
112
111
|
<form
|
|
113
112
|
class="pairing-form"
|
|
114
113
|
onSubmit=${(event) => {
|
|
115
114
|
event.preventDefault();
|
|
116
|
-
onStart(
|
|
115
|
+
onStart();
|
|
117
116
|
}}
|
|
118
117
|
>
|
|
119
|
-
<label>
|
|
120
|
-
${t('cloud.serviceUrl')}
|
|
121
|
-
<input
|
|
122
|
-
type="url"
|
|
123
|
-
required
|
|
124
|
-
placeholder="https://"
|
|
125
|
-
value=${url}
|
|
126
|
-
onInput=${(event) => setUrl(event.currentTarget.value)}
|
|
127
|
-
/>
|
|
128
|
-
</label>
|
|
129
118
|
<button type="submit" disabled=${busy}>${t('cloud.pair')}</button>
|
|
130
119
|
</form>
|
|
131
120
|
`;
|
|
@@ -279,6 +268,17 @@ export function CloudBackup() {
|
|
|
279
268
|
onStart=${(logbookId) => act('POST', '/cloud-sync/restore', { logbookId })}
|
|
280
269
|
/>`
|
|
281
270
|
}
|
|
271
|
+
${
|
|
272
|
+
status?.configured &&
|
|
273
|
+
html`<button
|
|
274
|
+
type="button"
|
|
275
|
+
class="danger"
|
|
276
|
+
disabled=${busy}
|
|
277
|
+
onClick=${() => confirm(t('cloud.unpairConfirm')) && act('DELETE', '/cloud-sync/token')}
|
|
278
|
+
>
|
|
279
|
+
${t('cloud.unpair')}
|
|
280
|
+
</button>`
|
|
281
|
+
}
|
|
282
282
|
${
|
|
283
283
|
status?.pairing &&
|
|
284
284
|
html`<${PairingState}
|
|
@@ -293,11 +293,7 @@ export function CloudBackup() {
|
|
|
293
293
|
<details open=${!ready}>
|
|
294
294
|
<summary>${ready ? t('cloud.pairAgain') : t('cloud.pairTitle')}</summary>
|
|
295
295
|
<p>${t('cloud.pairIntro')}</p>
|
|
296
|
-
<${PairingForm}
|
|
297
|
-
initialUrl=${status.url}
|
|
298
|
-
busy=${busy}
|
|
299
|
-
onStart=${(url) => act('POST', '/cloud-sync/pairing', { url })}
|
|
300
|
-
/>
|
|
296
|
+
<${PairingForm} busy=${busy} onStart=${() => act('POST', '/cloud-sync/pairing', {})} />
|
|
301
297
|
</details>
|
|
302
298
|
`
|
|
303
299
|
}
|
package/public/js/i18n.mjs
CHANGED
|
@@ -452,11 +452,13 @@ export const MESSAGES = {
|
|
|
452
452
|
'cloud.restored': 'Logbook restored: {count} passages brought back.',
|
|
453
453
|
'cloud.restoreRefused': '{count} could not be written; see the server log.',
|
|
454
454
|
'cloud.restoreFailed': 'The restore stopped: {message}',
|
|
455
|
-
'cloud.pairAgain': 'Pair with another account
|
|
455
|
+
'cloud.pairAgain': 'Pair with another account',
|
|
456
|
+
'cloud.unpair': 'Remove the pairing',
|
|
457
|
+
'cloud.unpairConfirm':
|
|
458
|
+
'Remove the pairing? The backup stops and this plugin forgets its token. What is already on Ti Bord stays there; revoke the device from your account to stop it for good.',
|
|
456
459
|
'cloud.pairIntro':
|
|
457
|
-
'
|
|
460
|
+
'Ask for a code, then enter it on Ti Bord, signed in to your account. The backup turns on as soon as it is claimed.',
|
|
458
461
|
'cloud.learnMore': 'More about the backups, and about sharing your passages and voyages:',
|
|
459
|
-
'cloud.serviceUrl': 'Service address',
|
|
460
462
|
'cloud.pair': 'Get a pairing code',
|
|
461
463
|
'cloud.enterCode': 'Enter this code on the service:',
|
|
462
464
|
'cloud.openClaim': 'Open the service to enter it',
|
|
@@ -1037,12 +1039,14 @@ export const MESSAGES = {
|
|
|
1037
1039
|
'cloud.restored': 'Journal restauré : {count} navigations récupérées.',
|
|
1038
1040
|
'cloud.restoreRefused': '{count} n’ont pas pu être écrites ; voir le journal du serveur.',
|
|
1039
1041
|
'cloud.restoreFailed': 'La restauration s’est arrêtée : {message}',
|
|
1040
|
-
'cloud.pairAgain': 'Appairer avec un autre compte
|
|
1042
|
+
'cloud.pairAgain': 'Appairer avec un autre compte',
|
|
1043
|
+
'cloud.unpair': 'Supprimer l’appairage',
|
|
1044
|
+
'cloud.unpairConfirm':
|
|
1045
|
+
'Supprimer l’appairage ? La sauvegarde s’arrête et ce plugin oublie son jeton. Ce qui est déjà sur Ti Bord y reste ; révoquez l’appareil depuis votre compte pour l’arrêter définitivement.',
|
|
1041
1046
|
'cloud.pairIntro':
|
|
1042
|
-
'
|
|
1047
|
+
'Demandez un code, puis saisissez-le sur Ti Bord, connecté à votre compte. La sauvegarde s’active dès qu’il est utilisé.',
|
|
1043
1048
|
'cloud.learnMore':
|
|
1044
1049
|
'Plus d’informations sur les sauvegardes et sur le partage de vos navigations et de vos voyages :',
|
|
1045
|
-
'cloud.serviceUrl': 'Adresse du service',
|
|
1046
1050
|
'cloud.pair': 'Obtenir un code d’appairage',
|
|
1047
1051
|
'cloud.enterCode': 'Saisissez ce code sur le service :',
|
|
1048
1052
|
'cloud.openClaim': 'Ouvrir le service pour le saisir',
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joel Kozikowski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var f="plotterExt/1",o={PARSE_ERROR:-32700,INVALID_REQUEST:-32600,METHOD_NOT_FOUND:-32601,INVALID_PARAMS:-32602,INTERNAL_ERROR:-32603,HOST_ERROR:-32e3,TIMEOUT:-32001,CONNECTION_CLOSED:-32002},c=class l extends Error{code;data;constructor(e,s={}){super(e),this.name="RpcError",this.code=s.code??o.HOST_ERROR;let n={...s.data??{}};s.reason!==void 0&&(n.reason=s.reason),this.data=Object.keys(n).length>0?n:void 0}get reason(){return typeof this.data?.reason=="string"?this.data.reason:void 0}toErrorObject(){return{code:this.code,message:this.message,...this.data?{data:this.data}:{}}}static fromErrorObject(e){return new l(e.message,{code:e.code,data:e.data})}static from(e){return e instanceof l?e:e instanceof Error?new l(e.message,{code:o.INTERNAL_ERROR}):new l(String(e),{code:o.INTERNAL_ERROR})}},u="bus.ready",y="bus.handshake";function E(t,e){return t===e?!0:m(t.split("."),0,e.split("."),0)}function m(t,e,s,n){for(;e<t.length;){let i=t[e];if(i==="**"){if(e===t.length-1)return!0;for(let r=n;r<=s.length;r++)if(m(t,e+1,s,r))return!0;return!1}if(n>=s.length||i!=="*"&&i!==s[n])return!1;e++,n++}return n===s.length}function g(t,e){for(let s of t)if(E(s,e))return!0;return!1}function N(t){return{bus:f,msg:t}}function M(t){if(typeof t!="object"||t===null)return null;let e=t;return e.bus!==f?null:_(e.msg)?e.msg:null}function _(t){if(typeof t!="object"||t===null)return!1;let e=t;if(e.jsonrpc!=="2.0")return!1;if(typeof e.method=="string")return e.id===void 0||typeof e.id=="string"||typeof e.id=="number";if(!(typeof e.id=="string"||typeof e.id=="number"||e.id===null))return!1;let n="result"in e,i=e.error,r=typeof i=="object"&&i!==null&&typeof i.code=="number"&&typeof i.message=="string";return n?!("error"in e):r}function S(t){return"method"in t&&"id"in t&&t.id!==void 0}function A(t){return"method"in t&&(!("id"in t)||t.id===void 0)}function D(t){return!("method"in t)}function h(t,e={}){let s=e.listenWindow??globalThis,n=e.origin??s.location?.origin??"*";return{post(i,r){r?.length?t.postMessage(i,{targetOrigin:n,transfer:r}):t.postMessage(i,n)},listen(i){let r=a=>{a.source===t&&(n!=="*"&&a.origin!==n||i(a.data))};return s.addEventListener("message",r),()=>s.removeEventListener("message",r)}}}var L=1e4,p=class{callTimeoutMs;port;unlisten;onError;pending=new Map;methods=new Map;eventHandlers=new Set;idPrefix=Math.random().toString(36).slice(2,8);seq=0;closed=!1;constructor(t){this.port=t.port,this.callTimeoutMs=t.callTimeoutMs??L,this.onError=t.onError??(e=>console.warn("[plotterext-bus]",e)),this.unlisten=this.port.listen(e=>this.onData(e))}registerMethod(t,e){this.methods.set(t,e)}unregisterMethod(t){this.methods.delete(t)}onEvent(t,e){let s={patterns:t,fn:e};return this.eventHandlers.add(s),()=>this.eventHandlers.delete(s)}notify(t,e,s={}){this.send({jsonrpc:"2.0",method:t,...e!==void 0?{params:e}:{}},s.transfer)}call(t,e,s={}){if(this.closed)return Promise.reject(new c("Bus endpoint is closed",{code:o.CONNECTION_CLOSED,reason:"CLOSED"}));let n=`${this.idPrefix}-${++this.seq}`,i=s.timeoutMs??this.callTimeoutMs;return new Promise((r,a)=>{let d=i>0?setTimeout(()=>{this.pending.delete(n),a(new c(`Call timed out after ${i}ms: ${t}`,{code:o.TIMEOUT,reason:"TIMEOUT"}))},i):null;this.pending.set(n,{resolve:r,reject:a,timer:d}),this.send({jsonrpc:"2.0",id:n,method:t,...e!==void 0?{params:e}:{}},s.transfer)})}close(){if(!this.closed){this.closed=!0,this.unlisten();for(let[,t]of this.pending)t.timer&&clearTimeout(t.timer),t.reject(new c("Bus endpoint closed",{code:o.CONNECTION_CLOSED,reason:"CLOSED"}));this.pending.clear(),this.eventHandlers.clear()}}send(t,e){this.closed||this.port.post(N(t),e)}onData(t){let e=M(t);e&&(D(e)?this.onResponse(e):S(e)?this.onRequest(e):A(e)&&this.onNotification(e.method,e.params))}onResponse(t){if(t.id===null)return;let e=this.pending.get(t.id);e&&(this.pending.delete(t.id),e.timer&&clearTimeout(e.timer),"error"in t?e.reject(c.fromErrorObject(t.error)):e.resolve(t.result))}async onRequest(t){let e=this.methods.get(t.method);if(!e){this.send({jsonrpc:"2.0",id:t.id,error:{code:o.METHOD_NOT_FOUND,message:`Method not found: ${t.method}`}});return}try{let s=await e(t.params,{endpoint:this});this.send({jsonrpc:"2.0",id:t.id,result:s===void 0?null:s})}catch(s){this.send({jsonrpc:"2.0",id:t.id,error:c.from(s).toErrorObject()})}}onNotification(t,e){for(let s of[...this.eventHandlers])if(g(s.patterns,t))try{s.fn(t,e)}catch(n){this.onError(n)}}};var v=class{handshake;endpoint;constructor(t,e){this.endpoint=t,this.handshake=e}get context(){return this.handshake.context}get apiVersion(){return this.handshake.apiVersion}get capabilities(){return this.handshake.capabilities}hasCapability(t){return this.handshake.capabilities.includes(t)}call(t,e,s){return this.endpoint.call(t,e,s)}notify(t,e){this.endpoint.notify(t,e)}async subscribe(t,e){let s=this.endpoint.onEvent(t,e),n;try{n=(await this.call("events.subscribe",{patterns:t})).subscriptionId}catch(i){throw s(),i}return async()=>{s(),await this.call("events.unsubscribe",{subscriptionId:n}).catch(()=>{})}}async publish(t,e,s){await this.call("events.publish",{topic:t,...e!==void 0?{params:e}:{},...s?{scope:s}:{}})}state={get:async(t,e)=>(await this.call("state.get",{...e?{scope:e}:{},...t?{keys:t}:{}})).values??{},set:async(t,e)=>{await this.call("state.set",{...e?{scope:e}:{},values:t})}};signalk={subscribe:async(t,e)=>{let s=t.map(r=>`sk.${r}`),n=await this.subscribe(s,(r,a)=>e(a)),i;try{i=(await this.call("signalk.subscribe",{paths:t})).subscriptionId}catch(r){throw await n(),r}return async()=>{await n(),await this.call("signalk.unsubscribe",{subscriptionId:i}).catch(()=>{})}},put:(t,e)=>this.call("signalk.put",{path:t,value:e})};route={list:async()=>(await this.call("route.list")).routes??[],create:async t=>await this.call("route.create",t),get:async t=>await this.call("route.get",{routeId:t}),replace:async(t,e)=>await this.call("route.replace",{routeId:t,points:e}),save:async(t,e)=>await this.call("route.save",{routeId:t,...e??{}}),show:async t=>await this.call("route.show",{ref:t}),hide:async t=>{await this.call("route.hide",{routeId:t})},delete:async t=>{await this.call("route.delete",{routeId:t})}};chart={list:async()=>(await this.call("chart.list")).charts??[],setVisibility:async(t,e)=>{await this.call("chart.setVisibility",{ids:t,visible:e})},setOpacity:async(t,e)=>{await this.call("chart.setOpacity",{ids:t,opacity:e})},setOrder:async t=>{await this.call("chart.setOrder",{order:t})},setTime:async(t,e)=>{await this.call("chart.setTime",{ids:t,time:e})}};nightMode={get:async()=>await this.call("nightMode.get"),set:async t=>{await this.call("nightMode.set",t)}};panels={list:async()=>(await this.call("ui.listPanels")).panels};overlays={list:async()=>(await this.call("overlay.list")).overlays,setVisibility:async(t,e)=>{await this.call("overlay.setVisibility",{ids:t,visible:e})},setOpacity:async(t,e)=>{await this.call("overlay.setOpacity",{ids:t,opacity:e})},frame:async t=>await this.call("overlay.frame",t,{transfer:[t.image]}),refresh:async t=>{await this.call("overlay.refresh",t)},setData:async t=>{await this.call("overlay.setData",t)},setLayer:async t=>{await this.call("overlay.setLayer",t)},setState:async(t,e)=>{await this.call("overlay.setState",{overlayId:t,values:e})}};windows={open:async t=>await this.call("ui.openWindow",t),update:async t=>await this.call("ui.updateWindow",t),focus:async t=>{await this.call("ui.focusWindow",t===void 0?{}:{windowId:t})},close:async t=>{await this.call("ui.closeWindow",t===void 0?{}:{windowId:t})},list:async()=>(await this.call("ui.listWindows")).windows};resourceGroup={apply:async t=>await this.call("resourceGroup.apply",{id:t})};map={getView:async()=>await this.call("map.getView"),center:async(t,e)=>{await this.call("map.center",{position:t,...typeof e=="number"?{zoom:e}:{}})},fitBounds:async t=>{await this.call("map.fitBounds",{bounds:t})}};close(){this.endpoint.close()}};function R(t={}){let e=t.port??h(globalThis.parent,{origin:"*"}),s=new p({port:e,callTimeoutMs:t.callTimeoutMs,onError:t.onError}),n=t.id?{id:t.id}:void 0;return new Promise((i,r)=>{let a=!1,d=s.onEvent([y],(I,T)=>{a||(a=!0,w(),i(new v(s,T)))}),b=setInterval(()=>s.notify(u,n),t.readyIntervalMs??250),O=setTimeout(()=>{a||(a=!0,w(),s.close(),r(new c("Timed out waiting for host handshake",{code:o.TIMEOUT,reason:"HANDSHAKE_TIMEOUT"})))},t.timeoutMs??1e4),w=()=>{d(),clearInterval(b),clearTimeout(O)};s.notify(u,n)})}export{R as connectExtension,h as windowPort};
|