signalk-siparu 0.2.17 → 0.2.19

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/NOTICE CHANGED
@@ -40,3 +40,22 @@ Map data and assets served from the tile host (not bundled in this package):
40
40
  OpenStreetMap contributors - ODbL 1.0 - https://www.openstreetmap.org/copyright
41
41
  Protomaps basemaps - BSD-3-Clause - https://github.com/protomaps/basemaps
42
42
  Noto Sans glyphs - SIL OFL 1.1 - https://github.com/protomaps/basemaps-assets
43
+
44
+ -------------------------------------------------------------------------------
45
+ GeoNames gazetteer - https://www.geonames.org
46
+ Creative Commons Attribution 4.0 - https://creativecommons.org/licenses/by/4.0/
47
+
48
+ plugin/data/places.json.gz is an extract of coastal places (harbours, bays and the
49
+ towns behind them) from the GeoNames dump, built by tools/build-places.mjs. The
50
+ plugin uses it aboard to name where a voyage began and ended.
51
+ Current file: built 2026-10-06 from the GeoNames dump of 2026-10-06 and the World
52
+ Port Index as published on 2026-10-06;
53
+ sha256 7ed46496ab48dde6ed2d6b07878cb7404f659aeb16475ea4e06a96993dc4ac72.
54
+
55
+ -------------------------------------------------------------------------------
56
+ World Port Index (Publication 150) - https://msi.nga.mil
57
+ National Geospatial-Intelligence Agency, United States. A work of the US government,
58
+ in the public domain.
59
+
60
+ Port names and positions from the index are folded into plugin/data/places.json.gz
61
+ where GeoNames does not carry the port under the same name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-siparu",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
4
4
  "description": "Kept aboard, proven ashore. Position, wind, depth and voyage history, recorded on the boat and readable from anywhere: sealed to the devices you name, signed on the way out. A read-only Signal K plugin with a built-in dashboard.",
5
5
  "keywords": [
6
6
  "signalk-node-server-plugin",
@@ -9,6 +9,7 @@
9
9
  "main": "plugin/dist/index.js",
10
10
  "files": [
11
11
  "plugin/dist",
12
+ "plugin/data",
12
13
  "public",
13
14
  "NOTICE"
14
15
  ],
Binary file
@@ -0,0 +1,5 @@
1
+ import type { IRouter, RequestHandler } from 'express';
2
+ export interface ReadRegistrar {
3
+ get(path: string, ...handlers: RequestHandler[]): unknown;
4
+ }
5
+ export declare function readable(router: IRouter): ReadRegistrar;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readable = readable;
4
+ function readable(router) {
5
+ const access = router.access;
6
+ if (typeof access === 'function') {
7
+ const registrar = access.call(router, 'readonly');
8
+ if (registrar && typeof registrar.get === 'function')
9
+ return registrar;
10
+ }
11
+ return router;
12
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ServerAPI } from '@signalk/server-api';
2
2
  import type { IRouter } from 'express';
3
+ import { allowConfigure } from './pairing';
3
4
  interface ConfigDeps {
4
5
  app: ServerAPI;
5
6
  acceptOpenNetwork: () => boolean;
@@ -10,6 +11,5 @@ interface ConfigDeps {
10
11
  };
11
12
  restart: (configuration: object) => void;
12
13
  }
13
- export declare function allowConfigure(app: ServerAPI, req: unknown): boolean;
14
+ export { allowConfigure };
14
15
  export declare function registerConfigRoutes(router: IRouter, deps: ConfigDeps): void;
15
- export {};
@@ -1,18 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.allowConfigure = allowConfigure;
3
+ exports.allowConfigure = void 0;
4
4
  exports.registerConfigRoutes = registerConfigRoutes;
5
5
  const pairing_1 = require("./pairing");
6
+ Object.defineProperty(exports, "allowConfigure", { enumerable: true, get: function () { return pairing_1.allowConfigure; } });
6
7
  const origin_guard_1 = require("./origin-guard");
7
- function allowConfigure(app, req) {
8
- try {
9
- const ss = app.securityStrategy;
10
- return ss?.allowConfigure?.(req) === true;
11
- }
12
- catch {
13
- return false;
14
- }
15
- }
8
+ const access_1 = require("./access");
16
9
  function readJsonBody(req) {
17
10
  return new Promise((resolve) => {
18
11
  const parsed = req.body;
@@ -41,7 +34,7 @@ function readJsonBody(req) {
41
34
  }
42
35
  function registerConfigRoutes(router, deps) {
43
36
  const { app, acceptOpenNetwork, getConfig, fuelPathsView, restart } = deps;
44
- router.get('/config/fuel-paths', (_req, res) => {
37
+ (0, access_1.readable)(router).get('/config/fuel-paths', (_req, res) => {
45
38
  res.json(fuelPathsView());
46
39
  });
47
40
  router.post('/config/fuel-paths', (0, origin_guard_1.sameOrigin)((req, res) => {
@@ -51,7 +44,7 @@ function registerConfigRoutes(router, deps) {
51
44
  return;
52
45
  }
53
46
  }
54
- else if (!allowConfigure(app, req)) {
47
+ else if (!(0, pairing_1.allowConfigure)(app, req)) {
55
48
  res.status(403).json({ error: 'admin_required' });
56
49
  return;
57
50
  }
@@ -60,6 +60,7 @@ const approval_1 = require("./approval");
60
60
  const keystore_1 = require("./keystore");
61
61
  const keysync_1 = require("./keysync");
62
62
  const sealer_1 = require("./sealer");
63
+ const places_1 = require("./places");
63
64
  const voyagelog_1 = require("./voyagelog");
64
65
  const phaselog_1 = require("./phaselog");
65
66
  const PLUGIN_ID = 'siparu';
@@ -303,7 +304,7 @@ module.exports = (app) => {
303
304
  rollups = ru;
304
305
  const qs = new query_1.QueryService(st, ru);
305
306
  query = qs;
306
- const vl = new voyagelog_1.VoyageLog(st, opts, (msg) => app.debug(msg));
307
+ const vl = new voyagelog_1.VoyageLog(st, opts, (msg) => app.debug(msg), places_1.Gazetteer.lazy(places_1.PLACES_FILE, (msg) => app.debug(msg)));
307
308
  voyages = vl;
308
309
  const pl = new phaselog_1.PhaseLog(st, opts, (msg) => app.debug(msg));
309
310
  phases = pl;
@@ -8,11 +8,11 @@ export type PairScreen = {
8
8
  state: 'idle';
9
9
  } | {
10
10
  state: 'showing_code';
11
- userCode: string;
11
+ userCode: string | null;
12
12
  expiresAt: string;
13
13
  } | {
14
14
  state: 'awaiting_approval';
15
- userCode: string;
15
+ userCode: string | null;
16
16
  email: string | null;
17
17
  expiresAt: string;
18
18
  device?: {
@@ -38,6 +38,9 @@ export type PairStatus = PairScreen & {
38
38
  };
39
39
  export declare function securityOff(app: ServerAPI, req: unknown): boolean;
40
40
  export declare function writeLocked(app: ServerAPI, req: unknown, acceptOpenNetwork: boolean): boolean;
41
+ export declare function allowConfigure(app: ServerAPI, req: unknown): boolean;
42
+ export declare function privileged(app: ServerAPI, req: unknown): boolean;
43
+ export declare function forTheCrew(screen: PairScreen): PairScreen;
41
44
  export declare const WRITE_LOCKED_MESSAGE = "Signal K security is off, so Siparu will not pair, unpair or edit the log. Add an admin user in Signal K, or accept the open-network risk in the plugin settings.";
42
45
  interface Deps {
43
46
  app: ServerAPI;
@@ -3,10 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RelayRefused = exports.WRITE_LOCKED_MESSAGE = void 0;
4
4
  exports.securityOff = securityOff;
5
5
  exports.writeLocked = writeLocked;
6
+ exports.allowConfigure = allowConfigure;
7
+ exports.privileged = privileged;
8
+ exports.forTheCrew = forTheCrew;
6
9
  exports.maskEmail = maskEmail;
7
10
  exports.registerPairRoutes = registerPairRoutes;
8
11
  exports.retryPendingUnlinks = retryPendingUnlinks;
9
12
  exports.__resetPairingState = __resetPairingState;
13
+ const access_1 = require("./access");
10
14
  const fingerprint_1 = require("./fingerprint");
11
15
  const origin_guard_1 = require("./origin-guard");
12
16
  function securityOff(app, req) {
@@ -21,6 +25,30 @@ function securityOff(app, req) {
21
25
  function writeLocked(app, req, acceptOpenNetwork) {
22
26
  return securityOff(app, req) && !acceptOpenNetwork;
23
27
  }
28
+ function allowConfigure(app, req) {
29
+ try {
30
+ const ss = app.securityStrategy;
31
+ return ss?.allowConfigure?.(req) === true;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ function privileged(app, req) {
38
+ return securityOff(app, req) || allowConfigure(app, req);
39
+ }
40
+ function forTheCrew(screen) {
41
+ switch (screen.state) {
42
+ case 'showing_code':
43
+ return { state: 'showing_code', userCode: null, expiresAt: screen.expiresAt };
44
+ case 'awaiting_approval':
45
+ return { state: 'awaiting_approval', userCode: null, email: null, expiresAt: screen.expiresAt };
46
+ case 'paired':
47
+ return { ...screen, email: null };
48
+ default:
49
+ return screen;
50
+ }
51
+ }
24
52
  exports.WRITE_LOCKED_MESSAGE = 'Signal K security is off, so Siparu will not pair, unpair or edit the log. Add an admin user in Signal K, or accept the open-network risk in the plugin settings.';
25
53
  let deviceCode = null;
26
54
  let userCode = null;
@@ -110,8 +138,9 @@ function registerPairRoutes(router, deps) {
110
138
  pairedAt: remote.pairedAt,
111
139
  uplink: uplinkStatus() ?? undefined
112
140
  });
113
- router.get('/pair/status', (0, origin_guard_1.sameOrigin)((req, res) => {
114
- const json = (screen) => {
141
+ (0, access_1.readable)(router).get('/pair/status', (0, origin_guard_1.sameOrigin)((req, res) => {
142
+ const json = (full) => {
143
+ const screen = privileged(app, req) ? full : forTheCrew(full);
115
144
  const status = { ...screen };
116
145
  if (securityOff(app, req)) {
117
146
  status.security_off = true;
@@ -0,0 +1,19 @@
1
+ export type PlaceResolver = (lat: number | null, lon: number | null) => string | null;
2
+ export declare const PLACES_FILE: string;
3
+ export declare const PLACE_RADIUS_NM = 5;
4
+ export declare const WATER_HANDICAP_NM = 1;
5
+ export declare const HAMLET_HANDICAP_NM = 0.75;
6
+ export declare class Gazetteer {
7
+ private readonly lat;
8
+ private readonly lon;
9
+ private readonly kind;
10
+ private readonly names;
11
+ private constructor();
12
+ static parse(text: string): Gazetteer;
13
+ static fromFile(file: string, log?: (msg: string) => void): Gazetteer | null;
14
+ static lazy(file: string, log?: (msg: string) => void, withinNm?: number): PlaceResolver;
15
+ get size(): number;
16
+ nearest(lat: number, lon: number, withinNm?: number): string | null;
17
+ private firstAtOrAbove;
18
+ resolver(withinNm?: number): PlaceResolver;
19
+ }
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.Gazetteer = exports.HAMLET_HANDICAP_NM = exports.WATER_HANDICAP_NM = exports.PLACE_RADIUS_NM = exports.PLACES_FILE = void 0;
37
+ const fs = __importStar(require("node:fs"));
38
+ const path = __importStar(require("node:path"));
39
+ const node_zlib_1 = require("node:zlib");
40
+ const rollup_1 = require("./rollup");
41
+ const LAND = 0;
42
+ const WATER = 1;
43
+ const HAMLET = 2;
44
+ exports.PLACES_FILE = path.join(__dirname, '..', 'data', 'places.json.gz');
45
+ exports.PLACE_RADIUS_NM = 5;
46
+ exports.WATER_HANDICAP_NM = 1;
47
+ exports.HAMLET_HANDICAP_NM = 0.75;
48
+ function isRow(x) {
49
+ return (Array.isArray(x) &&
50
+ (x.length === 3 || (x.length === 4 && (x[3] === LAND || x[3] === WATER || x[3] === HAMLET))) &&
51
+ typeof x[0] === 'number' &&
52
+ typeof x[1] === 'number' &&
53
+ typeof x[2] === 'string' &&
54
+ x[0] >= -90 &&
55
+ x[0] <= 90 &&
56
+ x[1] >= -180 &&
57
+ x[1] <= 180 &&
58
+ x[2].length > 0);
59
+ }
60
+ class Gazetteer {
61
+ lat;
62
+ lon;
63
+ kind;
64
+ names;
65
+ constructor(lat, lon, kind, names) {
66
+ this.lat = lat;
67
+ this.lon = lon;
68
+ this.kind = kind;
69
+ this.names = names;
70
+ }
71
+ static parse(text) {
72
+ const parsed = JSON.parse(text);
73
+ const rows = Array.isArray(parsed.places) ? parsed.places.filter(isRow) : [];
74
+ rows.sort((a, b) => a[0] - b[0]);
75
+ const lat = new Float64Array(rows.length);
76
+ const lon = new Float64Array(rows.length);
77
+ const kind = new Uint8Array(rows.length);
78
+ const names = new Array(rows.length);
79
+ rows.forEach((r, i) => {
80
+ lat[i] = r[0];
81
+ lon[i] = r[1];
82
+ kind[i] = r[3] ?? 0;
83
+ names[i] = r[2];
84
+ });
85
+ return new Gazetteer(lat, lon, kind, names);
86
+ }
87
+ static fromFile(file, log) {
88
+ try {
89
+ const bytes = fs.readFileSync(file);
90
+ const text = bytes[0] === 0x1f && bytes[1] === 0x8b ? (0, node_zlib_1.gunzipSync)(bytes).toString('utf8') : bytes.toString('utf8');
91
+ const g = Gazetteer.parse(text);
92
+ if (g.size === 0) {
93
+ log?.(`places: ${file} holds no places; voyage ends stay positions`);
94
+ return null;
95
+ }
96
+ log?.(`places: ${g.size} places loaded from ${file}`);
97
+ return g;
98
+ }
99
+ catch (err) {
100
+ log?.(`places: gazetteer not loaded from ${file}: ${err}`);
101
+ return null;
102
+ }
103
+ }
104
+ static lazy(file, log, withinNm = exports.PLACE_RADIUS_NM) {
105
+ let loaded;
106
+ return (lat, lon) => {
107
+ if (lat === null || lon === null)
108
+ return null;
109
+ if (loaded === undefined)
110
+ loaded = Gazetteer.fromFile(file, log);
111
+ return loaded ? loaded.nearest(lat, lon, withinNm) : null;
112
+ };
113
+ }
114
+ get size() {
115
+ return this.names.length;
116
+ }
117
+ nearest(lat, lon, withinNm = exports.PLACE_RADIUS_NM) {
118
+ const band = withinNm / 60;
119
+ const cosLat = Math.cos((lat * Math.PI) / 180);
120
+ const lonBand = cosLat > 1e-6 ? band / cosLat : 360;
121
+ let best = null;
122
+ let bestScore = Infinity;
123
+ for (let i = this.firstAtOrAbove(lat - band); i < this.names.length; i++) {
124
+ const rowLat = this.lat[i];
125
+ if (rowLat > lat + band)
126
+ break;
127
+ const rowLon = this.lon[i];
128
+ let dLon = Math.abs(rowLon - lon);
129
+ if (dLon > 180)
130
+ dLon = 360 - dLon;
131
+ if (dLon > lonBand)
132
+ continue;
133
+ const d = (0, rollup_1.haversineNm)(lat, lon, rowLat, rowLon);
134
+ if (d >= withinNm)
135
+ continue;
136
+ const k = this.kind[i];
137
+ const score = k === WATER ? d + exports.WATER_HANDICAP_NM : k === HAMLET ? d + exports.HAMLET_HANDICAP_NM : d;
138
+ if (score < bestScore) {
139
+ bestScore = score;
140
+ best = this.names[i];
141
+ }
142
+ }
143
+ return best;
144
+ }
145
+ firstAtOrAbove(lat) {
146
+ let lo = 0;
147
+ let hi = this.lat.length;
148
+ while (lo < hi) {
149
+ const mid = (lo + hi) >>> 1;
150
+ if (this.lat[mid] < lat)
151
+ lo = mid + 1;
152
+ else
153
+ hi = mid;
154
+ }
155
+ return lo;
156
+ }
157
+ resolver(withinNm = exports.PLACE_RADIUS_NM) {
158
+ return (lat, lon) => (lat === null || lon === null ? null : this.nearest(lat, lon, withinNm));
159
+ }
160
+ }
161
+ exports.Gazetteer = Gazetteer;
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.setRestDeps = setRestDeps;
37
37
  exports.registerRoutes = registerRoutes;
38
38
  const nodePath = __importStar(require("node:path"));
39
+ const access_1 = require("./access");
39
40
  const charts_1 = require("./charts");
40
41
  const query_1 = require("./query");
41
42
  let deps = null;
@@ -60,17 +61,18 @@ function intParam(req, name) {
60
61
  return n;
61
62
  }
62
63
  function registerRoutes(router) {
63
- router.get('/live', (_req, res) => {
64
+ const read = (0, access_1.readable)(router);
65
+ read.get('/live', (_req, res) => {
64
66
  if (!deps)
65
67
  return sendError(res, 503, 'NOT_STARTED', 'plugin is not started');
66
68
  res.json(deps.live());
67
69
  });
68
- router.get('/inventory', (_req, res) => {
70
+ read.get('/inventory', (_req, res) => {
69
71
  if (!deps)
70
72
  return sendError(res, 503, 'NOT_STARTED', 'plugin is not started');
71
73
  res.json(deps.inventory());
72
74
  });
73
- router.get('/snapshots', (req, res) => {
75
+ read.get('/snapshots', (req, res) => {
74
76
  if (!deps)
75
77
  return sendError(res, 503, 'NOT_STARTED', 'plugin is not started');
76
78
  let q;
@@ -97,7 +99,7 @@ function registerRoutes(router) {
97
99
  sendInternal(res, err);
98
100
  });
99
101
  });
100
- router.get('/health', (_req, res) => {
102
+ read.get('/health', (_req, res) => {
101
103
  if (!deps)
102
104
  return sendError(res, 503, 'NOT_STARTED', 'plugin is not started');
103
105
  deps
@@ -106,7 +108,7 @@ function registerRoutes(router) {
106
108
  .catch((err) => sendInternal(res, err));
107
109
  });
108
110
  const asyncGet = (path, handler) => {
109
- router.get(path, (req, res) => {
111
+ read.get(path, (req, res) => {
110
112
  if (!deps)
111
113
  return sendError(res, 503, 'NOT_STARTED', 'plugin is not started');
112
114
  const d = deps;
@@ -144,7 +146,7 @@ function registerRoutes(router) {
144
146
  return d.rollupHours(from, to);
145
147
  });
146
148
  asyncGet('/map-config', async (d) => d.mapConfig());
147
- router.get('/charts/*', (req, res) => {
149
+ read.get('/charts/*', (req, res) => {
148
150
  if (!deps)
149
151
  return sendError(res, 503, 'NOT_STARTED', 'plugin is not started');
150
152
  const rest = req.params['0'];
@@ -4,6 +4,7 @@ exports.registerVoyageEditRoutes = registerVoyageEditRoutes;
4
4
  const pairing_1 = require("./pairing");
5
5
  const config_routes_1 = require("./config-routes");
6
6
  const origin_guard_1 = require("./origin-guard");
7
+ const access_1 = require("./access");
7
8
  const STATUS = {
8
9
  not_found: 404,
9
10
  no_previous: 409,
@@ -12,7 +13,7 @@ const STATUS = {
12
13
  };
13
14
  function registerVoyageEditRoutes(router, deps) {
14
15
  const { app, acceptOpenNetwork, edits, mergeWithPrevious, undoMerge } = deps;
15
- router.get('/voyages/edits', (_req, res) => {
16
+ (0, access_1.readable)(router).get('/voyages/edits', (_req, res) => {
16
17
  res.json(edits());
17
18
  });
18
19
  const edit = (name, run) => {
@@ -1,6 +1,7 @@
1
1
  import { Snapshot, TrackPoint, Voyage, VoyageStatsResult } from './contract';
2
2
  import { Options } from './config';
3
3
  import { RollupEngine } from './rollup';
4
+ import { PlaceResolver } from './places';
4
5
  import { Logger, Store } from './store';
5
6
  export type EditResult = {
6
7
  ok: true;
@@ -13,13 +14,15 @@ export declare class VoyageLog {
13
14
  private store;
14
15
  private opts;
15
16
  private log;
17
+ private place;
16
18
  private state;
17
19
  private manual;
18
20
  private window;
19
21
  private chain;
20
22
  private persistedRevision;
21
23
  private filePath;
22
- constructor(store: Store, opts: Options, log: Logger);
24
+ constructor(store: Store, opts: Options, log: Logger, place?: PlaceResolver);
25
+ private named;
23
26
  private enqueue;
24
27
  init(now: number): Promise<void>;
25
28
  feed(snap: Snapshot): Promise<void>;
@@ -45,18 +45,29 @@ class VoyageLog {
45
45
  store;
46
46
  opts;
47
47
  log;
48
+ place;
48
49
  state = { voyages: [], nextId: 1 };
49
50
  manual = [];
50
51
  window = [];
51
52
  chain = Promise.resolve();
52
53
  persistedRevision = '';
53
54
  filePath;
54
- constructor(store, opts, log) {
55
+ constructor(store, opts, log, place = () => null) {
55
56
  this.store = store;
56
57
  this.opts = opts;
57
58
  this.log = log;
59
+ this.place = place;
58
60
  this.filePath = path.join(store.rollupDir, 'voyages.json');
59
61
  }
62
+ named(v) {
63
+ const start_port = v.start_port ?? (0, voyage_1.nearestPort)(v.start_lat, v.start_lon, this.opts.ports) ?? this.place(v.start_lat, v.start_lon);
64
+ const end_port = v.status === 'closed'
65
+ ? (v.end_port ?? (0, voyage_1.nearestPort)(v.end_lat, v.end_lon, this.opts.ports) ?? this.place(v.end_lat, v.end_lon))
66
+ : v.end_port;
67
+ if (start_port === v.start_port && end_port === v.end_port)
68
+ return v;
69
+ return { ...v, start_port, end_port };
70
+ }
60
71
  enqueue(job) {
61
72
  const run = this.chain.then(job);
62
73
  this.chain = run.then(() => undefined, (err) => this.log(`voyage error: ${err}`));
@@ -94,10 +105,14 @@ class VoyageLog {
94
105
  });
95
106
  }
96
107
  list(limit) {
97
- return [...this.state.voyages].sort((a, b) => b.start_ts - a.start_ts).slice(0, limit);
108
+ return [...this.state.voyages]
109
+ .sort((a, b) => b.start_ts - a.start_ts)
110
+ .slice(0, limit)
111
+ .map((v) => this.named(v));
98
112
  }
99
113
  current() {
100
- return this.state.voyages.find((v) => v.status === 'open') ?? null;
114
+ const open = this.state.voyages.find((v) => v.status === 'open');
115
+ return open ? this.named(open) : null;
101
116
  }
102
117
  async track(voyageId, now) {
103
118
  const v = this.state.voyages.find((x) => x.id === voyageId);