signalk-chiplog 2.3.1 → 2.5.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 CHANGED
@@ -6,6 +6,36 @@ All notable changes to Chiplog are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.5.0] - 2026-09-18
10
+
11
+ ### Added
12
+
13
+ - **Import a PostgSail logbook.** `node scripts/import-postgsail.js <trips.geojson> --url <server>` reads PostgSail's
14
+ GeoJSON export and adds each trip as a passage — track, wind, engine and sail periods, place names, fuel level, house
15
+ battery, instrument snapshots — through the API, so the logbook can be on the boat's server. It is safe to run again:
16
+ a passage already on record is skipped. The route it uses, `POST /entries` (administrator), adds any finished passage
17
+ and answers `409 entry_overlaps` when it overlaps one on record. See the README's _Importing from PostgSail_.
18
+
19
+ ## [2.4.0] - 2026-09-18
20
+
21
+ ### Added
22
+
23
+ - **Previous and next passage links at the top of a passage page**, to step through the log without going back to it.
24
+ `GET /entries/:id` gives them as `previousEntryId` and `nextEntryId`, in the log's order. Alt+← and Alt+→ do the same
25
+ from the keyboard.
26
+ - **A Statistics page** sums the logbook up over a period: number of passages, first and last dates, total distance,
27
+ time under way, top speed, strongest wind, the longest passage without a stop (a passage is cut at its stopovers, each
28
+ stretch measured on its own), the flags of the countries visited, and the top 5 passages by duration, distance,
29
+ average speed, top speed and wind. The period is a shortcut — all time, this month, last month, the last 12 months,
30
+ this year, last year — or a range picked in a calendar with two clicks, the first and the last day. It reads
31
+ `GET /statistics`.
32
+ - **One range picker for every date range** in the webapp: the Animation, Export and Retrospective pages use the same
33
+ calendar — two clicks, the first and the last day — instead of a pair of date fields, so a range with its end before
34
+ its start can no longer be entered.
35
+ - **Places now know their country** (`countryCode` on `GET /places`), from the same geocoding lookup that names them;
36
+ places saved earlier, or added by hand, are asked about in the background once pending names are done. Database
37
+ migration 16.
38
+
9
39
  ## [2.3.1] - 2026-09-18
10
40
 
11
41
  ### Added
@@ -287,7 +317,9 @@ First release.
287
317
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
288
318
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
289
319
 
290
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.1...HEAD
320
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.5.0...HEAD
321
+ [2.5.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.4.0...v2.5.0
322
+ [2.4.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.1...v2.4.0
291
323
  [2.3.1]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.0...v2.3.1
292
324
  [2.3.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.2.0...v2.3.0
293
325
  [2.2.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.1.0...v2.2.0
package/README.md CHANGED
@@ -30,6 +30,7 @@ English and French, chosen from the browser's language.
30
30
  - [Signal K data used](#signal-k-data-used-)
31
31
  - [Backups and abandon ship](#backups-and-abandon-ship-)
32
32
  - [Retrospective analysis](#retrospective-analysis-)
33
+ - [Importing from PostgSail](#importing-from-postgsail-)
33
34
  - [Privacy and online services](#privacy-and-online-services-)
34
35
  - [Troubleshooting](#troubleshooting-)
35
36
  - [Limitations](#limitations-)
@@ -201,36 +202,48 @@ Open **Chiplog** from the Signal K webapps, or `/signalk-chiplog/`. Reading need
201
202
  not just what is loaded), then passages grouped by day, newest first, with times, departure and arrival, distance,
202
203
  duration and an engine/sail bar. A passage across midnight appears on both days. Provisional place names are shown as
203
204
  such.
204
- - **Passage page** — summary (distance, duration, average speed, the highest speed and wind seen, and the crew aboard),
205
- map of the track (OpenStreetMap with OpenSeaMap seamarks, which can be hidden) with a small boat marker at the
206
- selected point, a scrubber under the map to step back and forth through its history (defaulting to the latest point,
207
- so it shows the current position on a passage in progress) with a band of that point's time, SOG, COG, STW, TWS, TWD,
208
- TWA and AWA, the marine weather forecast every 3 hours from departure, the tide forecast near the departure (place,
209
- high/low times and heights, height curve) when one was fetched, the engine and sail periods, the boat's status (each
210
- engine's hour counter at departure and arrival and the hours run, and the tank levels and battery charge, voltage and
211
- current noted at departure), and the log: every reading and event in order, including handwritten notes. A passage in
212
- progress refreshes every minute. Each line's comment can be edited (read/write access); a manoeuvre or note the crew
213
- logged themselves can also be deleted automatic lines (alarms, autopilot, weather, corrections) can only be
214
- annotated. Under each position, in grey, its bearing and distance from the nearest landmark.
205
+ - **Passage page** — links at the top to the previous and the next passage (or **Alt+←** and **Alt+→**), then a summary
206
+ (distance, duration, average speed, the highest speed and wind seen, and the crew aboard), map of the track
207
+ (OpenStreetMap with OpenSeaMap seamarks, which can be hidden) with a small boat marker at the selected point, a
208
+ scrubber under the map to step back and forth through its history (defaulting to the latest point, so it shows the
209
+ current position on a passage in progress) with a band of that point's time, SOG, COG, STW, TWS, TWD, TWA and AWA, the
210
+ marine weather forecast every 3 hours from departure, the tide forecast near the departure (place, high/low times and
211
+ heights, height curve) when one was fetched, the engine and sail periods, the boat's status (each engine's hour
212
+ counter at departure and arrival and the hours run, and the tank levels and battery charge, voltage and current noted
213
+ at departure), and the log: every reading and event in order, including handwritten notes. A passage in progress
214
+ refreshes every minute. Each line's comment can be edited (read/write access); a manoeuvre or note the crew logged
215
+ themselves can also be deleted — automatic lines (alarms, autopilot, weather, corrections) can only be annotated.
216
+ Under each position, in grey, its bearing and distance from the nearest landmark.
215
217
  - **Corrections** (read/write access):
216
218
  - rename the departure, or the arrival once the passage is closed — a passage in progress has none yet to rename;
217
219
  - switch an engine period to sail or back;
218
220
  - close a passage in progress, e.g. to confirm an arrival;
219
221
  - merge with the previous or next passage;
220
222
  - delete a passage (admin).
221
- - **Animation** — pick two dates and every passage between them replays on the map, one after another, the port time
222
- skipped. The map follows the boat at a scale chosen for each passage a short hop kept readable rather than
223
- magnified, a long crossing allowed a wider view but never so wide the boat crawls across empty water while a bubble
224
- shows the speed, the distance covered since the start and the date. Play, pause and a slider over the animation's own
225
- time, at ×0,5, ×1, ×2 or ×4. **Export MP4** saves it as a video in one of five shapes (Mobile 9:16, Portrait 3:4,
226
- Square 1:1, Landscape 4:3, Widescreen 16:9). Everything happens in the browser: nothing is rendered or encoded on the
227
- Signal K server, and the map tiles are the only thing downloaded.
223
+ - **Statistics** — the logbook summed up over a period: a shortcut (all time, this month, last month, the last 12
224
+ months, this year, last year) or a range picked in a calendar with two clicks, the first and the last day. It gives
225
+ the number of passages, the dates of the first and the last, total distance, time under way, top speed and strongest
226
+ wind, the longest passage without a stop (a passage with a stopover counts as its stretches between stops) with its
227
+ distance and time, the flags of the countries visited, and the top 5 passages by duration, distance, average speed,
228
+ top speed and wind. Countries come from the place names, so they need geocoding enabled and the boat online now and
229
+ then; places named before this existed fill in by themselves.
230
+ - **Animation** — pick a period and every passage in it replays on the map, one after another, the port time skipped.
231
+ The map follows the boat at a scale chosen for each passage — a short hop kept readable rather than magnified, a long
232
+ crossing allowed a wider view but never so wide the boat crawls across empty water — while a bubble shows the speed,
233
+ the distance covered since the start and the date. Play, pause and a slider over the animation's own time, at ×0,5,
234
+ ×1, ×2 or ×4. **Export MP4** saves it as a video in one of five shapes (Mobile 9:16, Portrait 3:4, Square 1:1,
235
+ Landscape 4:3, Widescreen 16:9). Everything happens in the browser: nothing is rendered or encoded on the Signal K
236
+ server, and the map tiles are the only thing downloaded.
228
237
  - **Export** — download the whole logbook or a date range as a PDF logbook to print, JSON, CSV or GPX, and write the
229
238
  abandon-ship copy to the USB drive now (admin). The PDF is written in the webapp's language and the device's time
230
239
  zone.
231
240
  - **Retrospective** (admin) — reconstruct past passages for a date range from an InfluxDB history (see
232
241
  [Retrospective analysis](#retrospective-analysis-)).
233
242
 
243
+ Every date range in the webapp — Statistics, Animation, Export and Retrospective — is picked the same way: one button
244
+ showing the period, and a calendar under it where two clicks, the first and the last day, make the range. Statistics has
245
+ shortcuts beside it; where any date is allowed, the calendar has an _Any date_ button to clear the range.
246
+
234
247
  **Helm entry** in the top bar opens the tablet entry app.
235
248
 
236
249
  ## The tablet entry app 📱
@@ -405,11 +418,39 @@ at the time.
405
418
  track has one point per **Track point interval**. Everything else read from a continuously published path — position,
406
419
  speed, wind, engine, autopilot, depth, barometer — is reconstructed the same as live.
407
420
 
421
+ ## Importing from PostgSail 📥
422
+
423
+ Kept your logbook with [PostgSail](https://github.com/xbgmsharp/postgsail) until now? Its GeoJSON export — one trip or
424
+ several — comes into Chiplog with the script `scripts/import-postgsail.js`, run from a checkout of this repository (Node
425
+ 22.13 or later, nothing to install). It talks to the plugin's REST API, so the logbook can be on the boat's Signal K
426
+ server while you run it from your own computer.
427
+
428
+ ```bash
429
+ node scripts/import-postgsail.js PostgSail_Trip.geojson --url http://boat.local:3000 --token <token>
430
+ ```
431
+
432
+ - **Administrator access.** Give the token of an administrator with `--token` (or `CHIPLOG_TOKEN`), or sign in with
433
+ `--user` and `--password` (or `CHIPLOG_PASSWORD`); a server without Signal K security needs neither.
434
+ - **Each trip becomes a passage**, with its track, wind and speed, the engine and sail periods read from PostgSail's
435
+ _sailing_ and _motoring_ status, the places from the trip name, and instrument snapshots every hour on the clock
436
+ (`--observation-interval <minutes>`, `0` for none).
437
+ - **Safe to run again.** A passage that overlaps one already in the logbook is skipped, so a run cut short is finished
438
+ by running the same command; nothing is duplicated. `--dry-run` lists what the file holds without sending anything.
439
+ - **Boat state at departure.** PostgSail's tank level is taken as the fuel tank, and its voltage and state of charge as
440
+ the house battery, noted like Chiplog does live.
441
+ - **Not imported:** PostgSail's own distance — Chiplog sums the track, as for every passage, which lands within a few
442
+ percent of it.
443
+ - Place names come as PostgSail has them. Passages near a place Chiplog already knows are named the same; the countries
444
+ and landmarks are then looked up in the background, like for any passage. Tide and weather forecasts are not fetched
445
+ for passages this old.
446
+
408
447
  ## Privacy and online services 🔒
409
448
 
410
449
  - **Place names.** With geocoding on, the position of each departure and arrival that matches no known place is sent to
411
450
  the geocoding service — OpenStreetMap's public Nominatim by default. Nothing else is sent, and nothing at all when it
412
451
  is off.
452
+ - **Countries.** With geocoding on, a place whose country is not known yet has its position sent to the geocoding
453
+ service once, to ask which country it is in. Nothing at all when it is off.
413
454
  - **Landmarks.** With them on, the area a passage sailed through — a half-degree box, not its track — is sent to the
414
455
  Overpass service, OpenStreetMap's public instance by default, once per area ever. Nothing at all when it is off.
415
456
  - **Tide forecast.** With it on, the departure position of each passage is sent to the tide service — the public
@@ -535,6 +576,7 @@ npm install # also copies the browser libraries into public/vendor/
535
576
  npm test
536
577
  npm run lint
537
578
  npm run demo:seed -- /tmp/chiplog-demo # a demo logbook to try the webapps with
579
+ npm run import:postgsail -- <trips.geojson> --url <server> # see Importing from PostgSail
538
580
  ```
539
581
 
540
582
  The functional specification is in [docs/SPEC.md](docs/SPEC.md), the data model in
package/lib/api.js CHANGED
@@ -8,10 +8,12 @@ const { listEntryLandmarks } = require('./landmarks');
8
8
  const { isTimeZone, PDF_LANGUAGES } = require('./logbook-pdf');
9
9
  const { toGeoJson, toGpx } = require('./formats');
10
10
  const manoeuvreTypes = require('./manoeuvre-types');
11
+ const { importPassage, TRACK_READINGS, OBSERVATION_READINGS } = require('./passage-import');
11
12
  const places = require('./places');
12
13
  const propulsion = require('./propulsion');
13
14
  const { getTideForecast } = require('./tide-forecaster');
14
15
  const { getWeatherForecast } = require('./weather-forecaster');
16
+ const { getStatistics } = require('./statistics');
15
17
  const track = require('./track');
16
18
  const v = require('./validation');
17
19
 
@@ -80,6 +82,112 @@ function parseRange(query) {
80
82
  };
81
83
  }
82
84
 
85
+ const TRACK_POINT_FIELDS = ['time', 'lat', 'lon', ...TRACK_READINGS];
86
+ const OBSERVATION_FIELD_READINGS = OBSERVATION_READINGS.map(([, field]) => field);
87
+ const OBSERVATION_FIELDS = ['time', 'reason', 'position', ...OBSERVATION_FIELD_READINGS];
88
+ const OBSERVATION_REASONS = ['periodic', 'entry_start', 'entry_end', 'event'];
89
+
90
+ const nullableNumber = (value, name) => (value === null ? null : v.parseNumber(value, name));
91
+
92
+ // A list of objects in a request body, each checked field by field; an absent
93
+ // list is an empty one.
94
+ function parseList(body, field, parseItem) {
95
+ if (!(field in body)) {
96
+ return [];
97
+ }
98
+ if (!Array.isArray(body[field])) {
99
+ throw badRequest(`${field} must be an array`);
100
+ }
101
+ return body[field].map((item, index) => parseItem(item, `${field}[${index}]`));
102
+ }
103
+
104
+ function parseTrackPoint(item, name) {
105
+ const point = v.requireBody(item, TRACK_POINT_FIELDS);
106
+ const { lat, lon } = v.parsePosition({ lat: point.lat, lon: point.lon }, name);
107
+ return {
108
+ time: v.parseTimestamp(point.time, `${name}.time`),
109
+ lat,
110
+ lon,
111
+ ...Object.fromEntries(
112
+ TRACK_READINGS.filter((reading) => reading in point).map((reading) => [
113
+ reading,
114
+ nullableNumber(point[reading], `${name}.${reading}`)
115
+ ])
116
+ )
117
+ };
118
+ }
119
+
120
+ function parseObservation(item, name) {
121
+ const observation = v.requireBody(item, OBSERVATION_FIELDS);
122
+ return {
123
+ time: v.parseTimestamp(observation.time, `${name}.time`),
124
+ reason: v.parseEnum(observation.reason, `${name}.reason`, OBSERVATION_REASONS),
125
+ position: optional(observation, 'position', v.parsePosition),
126
+ ...Object.fromEntries(
127
+ OBSERVATION_FIELD_READINGS.filter((reading) => reading in observation).map((reading) => [
128
+ reading,
129
+ nullableNumber(observation[reading], `${name}.${reading}`)
130
+ ])
131
+ )
132
+ };
133
+ }
134
+
135
+ // A tank as noted at departure: level as a ratio, volume and capacity in m³.
136
+ function parseTank(item, name) {
137
+ const tank = v.requireBody(item, ['type', 'id', 'name', 'level', 'volume', 'capacity']);
138
+ const readings = ['level', 'volume', 'capacity'].filter((field) => tank[field] != null);
139
+ if (!('level' in tank || 'volume' in tank)) {
140
+ throw badRequest(`${name} needs a level or a volume`);
141
+ }
142
+ return {
143
+ type: v.parseString(tank.type, `${name}.type`, { maxLength: 40 }),
144
+ id: v.parseString(tank.id, `${name}.id`, { maxLength: 40 }),
145
+ ...(tank.name == null
146
+ ? {}
147
+ : { name: v.parseString(tank.name, `${name}.name`, { maxLength: 100 }) }),
148
+ ...Object.fromEntries(
149
+ readings.map((field) => [field, v.parseNonNegativeNumber(tank[field], `${name}.${field}`)])
150
+ )
151
+ };
152
+ }
153
+
154
+ // A battery as noted at departure: volts, amps (negative discharging), state of
155
+ // charge as a ratio, kelvin.
156
+ function parseBattery(item, name) {
157
+ const battery = v.requireBody(item, [
158
+ 'id',
159
+ 'name',
160
+ 'voltage',
161
+ 'current',
162
+ 'stateOfCharge',
163
+ 'temperature'
164
+ ]);
165
+ const readings = ['voltage', 'current', 'stateOfCharge', 'temperature'].filter(
166
+ (field) => battery[field] != null
167
+ );
168
+ if (readings.length === 0) {
169
+ throw badRequest(`${name} needs at least one reading`);
170
+ }
171
+ return {
172
+ id: v.parseString(battery.id, `${name}.id`, { maxLength: 40 }),
173
+ ...(battery.name == null
174
+ ? {}
175
+ : { name: v.parseString(battery.name, `${name}.name`, { maxLength: 100 }) }),
176
+ ...Object.fromEntries(
177
+ readings.map((field) => [field, v.parseNumber(battery[field], `${name}.${field}`)])
178
+ )
179
+ };
180
+ }
181
+
182
+ function parsePropulsionPeriod(item, name) {
183
+ const segment = v.requireBody(item, ['type', 'startTime', 'endTime']);
184
+ return {
185
+ type: v.parseEnum(segment.type, `${name}.type`, ['engine', 'sail']),
186
+ startTime: v.parseTimestamp(segment.startTime, `${name}.startTime`),
187
+ endTime: v.parseTimestamp(segment.endTime, `${name}.endTime`)
188
+ };
189
+ }
190
+
83
191
  function registerRoutes(router, { getContext, logError }) {
84
192
  const readonly = scoped(router, 'readonly');
85
193
  const readwrite = scoped(router, 'readwrite');
@@ -132,11 +240,57 @@ function registerRoutes(router, { getContext, logError }) {
132
240
  handle(({ db, now }, req) => entries.getStats(db, { ...parseRange(req.query), now: now() }))
133
241
  );
134
242
 
243
+ readonly.get(
244
+ '/api/statistics',
245
+ handle(({ db, now }, req) => getStatistics(db, { ...parseRange(req.query), now: now() }))
246
+ );
247
+
135
248
  readonly.get(
136
249
  '/api/entries/:id',
137
250
  handle(({ db }, req) => entries.getEntry(db, entryId(req)))
138
251
  );
139
252
 
253
+ // A finished passage recorded elsewhere, such as another logbook's export.
254
+ admin.post(
255
+ '/api/entries',
256
+ handle(({ db, config, now }, req, res) => {
257
+ const body = v.requireBody(req.body, [
258
+ 'startTime',
259
+ 'endTime',
260
+ 'startPosition',
261
+ 'endPosition',
262
+ 'startPlaceName',
263
+ 'endPlaceName',
264
+ 'distance',
265
+ 'startTanks',
266
+ 'startBatteries',
267
+ 'trackPoints',
268
+ 'observations',
269
+ 'propulsion'
270
+ ]);
271
+ const entry = importPassage(
272
+ db,
273
+ {
274
+ startTime: v.parseTimestamp(body.startTime, 'startTime'),
275
+ endTime: v.parseTimestamp(body.endTime, 'endTime'),
276
+ startPosition: optional(body, 'startPosition', v.parsePosition) ?? null,
277
+ endPosition: optional(body, 'endPosition', v.parsePosition) ?? null,
278
+ startPlaceName: optional(body, 'startPlaceName', nullableString(200)) ?? null,
279
+ endPlaceName: optional(body, 'endPlaceName', nullableString(200)) ?? null,
280
+ distance: optional(body, 'distance', v.parseNonNegativeNumber),
281
+ startTanks: parseList(body, 'startTanks', parseTank),
282
+ startBatteries: parseList(body, 'startBatteries', parseBattery),
283
+ trackPoints: parseList(body, 'trackPoints', parseTrackPoint),
284
+ observations: parseList(body, 'observations', parseObservation),
285
+ propulsion: parseList(body, 'propulsion', parsePropulsionPeriod)
286
+ },
287
+ { placeMatchRadius: config.placeMatchRadius, now: now() }
288
+ );
289
+ res.status(201);
290
+ return entry;
291
+ })
292
+ );
293
+
140
294
  readwrite.patch(
141
295
  '/api/entries/:id',
142
296
  handle(({ db, config, now }, req) => {
package/lib/database.js CHANGED
@@ -358,6 +358,19 @@ const MIGRATIONS = [
358
358
  -- passages default to pending, so their areas are fetched too.
359
359
  ALTER TABLE log_entries ADD COLUMN landmarks_pending INTEGER NOT NULL DEFAULT 1
360
360
  CHECK (landmarks_pending IN (0, 1));
361
+ `,
362
+ `
363
+ -- The country a place is in (SPEC §4.14), as an upper-case ISO 3166-1
364
+ -- alpha-2 code, for the statistics page's visited countries. It lives on the
365
+ -- place rather than on each entry: a passage reaches its country through
366
+ -- start_place_id / end_place_id. country_checked says the lookup has been
367
+ -- made, so a place whose position has no country (open water, or geocoding
368
+ -- switched off after the fact) is not asked about forever. Existing places
369
+ -- start unchecked and are filled in by the same background chain that names
370
+ -- pending departures.
371
+ ALTER TABLE places ADD COLUMN country_code TEXT;
372
+ ALTER TABLE places ADD COLUMN country_checked INTEGER NOT NULL DEFAULT 0
373
+ CHECK (country_checked IN (0, 1));
361
374
  `
362
375
  ];
363
376
 
package/lib/entries.js CHANGED
@@ -48,7 +48,7 @@ function requireEntryRow(db, id) {
48
48
  return row;
49
49
  }
50
50
 
51
- // Shared by listEntries and getStats, so a date range means the same thing
51
+ // Shared by listEntries, getStats and the statistics page, so a date range means the same thing
52
52
  // to both: matched on startTime, from inclusive and to exclusive.
53
53
  function rangeWhere({ from, to }) {
54
54
  const conditions = [];
@@ -94,8 +94,27 @@ function getStats(db, { from, to, now }) {
94
94
  return { count: row.count, distance: row.distance, duration: Math.round(row.duration) };
95
95
  }
96
96
 
97
+ // The passages either side of this one in the log's order (start time, then
98
+ // id), for a page to step to its neighbours.
99
+ function neighbourIds(db, row) {
100
+ const before = db
101
+ .prepare(
102
+ `SELECT id FROM log_entries WHERE start_time < ?1 OR (start_time = ?1 AND id < ?2)
103
+ ORDER BY start_time DESC, id DESC LIMIT 1`
104
+ )
105
+ .get(row.start_time, row.id);
106
+ const after = db
107
+ .prepare(
108
+ `SELECT id FROM log_entries WHERE start_time > ?1 OR (start_time = ?1 AND id > ?2)
109
+ ORDER BY start_time, id LIMIT 1`
110
+ )
111
+ .get(row.start_time, row.id);
112
+ return { previousEntryId: before?.id ?? null, nextEntryId: after?.id ?? null };
113
+ }
114
+
97
115
  function getEntry(db, id) {
98
- const entry = toEntry(requireEntryRow(db, id));
116
+ const row = requireEntryRow(db, id);
117
+ const entry = toEntry(row);
99
118
  const count = (table) =>
100
119
  db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE entry_id = ?`).get(id).n;
101
120
  const maxSpeed = db
@@ -116,6 +135,7 @@ function getEntry(db, id) {
116
135
  .get(id, id);
117
136
  return {
118
137
  ...entry,
138
+ ...neighbourIds(db, row),
119
139
  counts: {
120
140
  trackPoints: count('track_points'),
121
141
  observations: count('observations'),
@@ -488,6 +508,7 @@ function deleteEntry(db, id) {
488
508
  module.exports = {
489
509
  toEntry,
490
510
  requireEntryRow,
511
+ rangeWhere,
491
512
  listEntries,
492
513
  getStats,
493
514
  getEntry,
@@ -0,0 +1,190 @@
1
+ const { withTransaction } = require('./database');
2
+ const { badRequest, conflict } = require('./errors');
3
+ const { getEntry, recomputeDurations } = require('./entries');
4
+ const { initialPlaceName } = require('./place-names');
5
+ const { distanceBetween, findNearestPlace } = require('./places');
6
+
7
+ const TRACK_READINGS = ['sog', 'cog', 'stw', 'heading', 'tws', 'twd', 'aws', 'awa'];
8
+ // [column, request field]
9
+ const OBSERVATION_READINGS = [
10
+ ...TRACK_READINGS.map((reading) => [reading, reading]),
11
+ ['depth', 'depth'],
12
+ ['pressure', 'pressure'],
13
+ ['air_temp', 'airTemp'],
14
+ ['water_temp', 'waterTemp'],
15
+ ['trip_log', 'tripLog'],
16
+ ['engine_runtime', 'engineRuntime']
17
+ ];
18
+
19
+ // An anchorage is not left at the same spot twice: a place of the same name this close
20
+ // is the same place, though further than the matching radius.
21
+ const SAME_NAME_REACH_METRES = 500;
22
+
23
+ // A passage recorded elsewhere is only ever added next to the ones on record,
24
+ // never on top of them: the vessel was in one place at a time, so an overlap
25
+ // means the passage is already there, or that one of the two is wrong.
26
+ function requireNoOverlap(db, startTime, endTime) {
27
+ const clash = db
28
+ .prepare(
29
+ `SELECT id FROM log_entries
30
+ WHERE start_time < ? AND COALESCE(end_time, '9999-12-31T23:59:59.999Z') > ?
31
+ ORDER BY start_time LIMIT 1`
32
+ )
33
+ .get(endTime, startTime);
34
+ if (clash) {
35
+ throw conflict('entry_overlaps', `The passage overlaps entry ${clash.id} already on record`);
36
+ }
37
+ }
38
+
39
+ // The name comes from the source, so it is kept as given; the position is tied
40
+ // to a known place when there is one in reach, or made into one, so a later
41
+ // departure from the same spot is named the same way. Without a name it is
42
+ // named as a passage detected live would be.
43
+ function resolvePlace(db, position, name, radius, now) {
44
+ if (!position) {
45
+ return { id: null, name: name ?? null, pending: 0 };
46
+ }
47
+ if (name === null || name === undefined) {
48
+ return initialPlaceName(db, position, radius);
49
+ }
50
+ const known =
51
+ findNearestPlace(db, position, radius) ??
52
+ db
53
+ .prepare('SELECT * FROM places WHERE name = ? COLLATE NOCASE')
54
+ .all(name)
55
+ .find((place) => distanceBetween(position, place) <= SAME_NAME_REACH_METRES);
56
+ if (known) {
57
+ return { id: known.id, name, pending: 0 };
58
+ }
59
+ const { lastInsertRowid } = db
60
+ .prepare(
61
+ `INSERT INTO places (name, lat, lon, source, created_at, updated_at)
62
+ VALUES (?, ?, ?, 'manual', ?, ?)`
63
+ )
64
+ .run(name, position.lat, position.lon, now, now);
65
+ return { id: Number(lastInsertRowid), name, pending: 0 };
66
+ }
67
+
68
+ function requireWithin(startTime, endTime, time, name) {
69
+ if (time < startTime || time > endTime) {
70
+ throw badRequest(`${name} must fall between startTime and endTime`);
71
+ }
72
+ }
73
+
74
+ function trackDistance(points) {
75
+ let total = 0;
76
+ for (let i = 1; i < points.length; i += 1) {
77
+ total += distanceBetween(points[i - 1], points[i]);
78
+ }
79
+ return total;
80
+ }
81
+
82
+ // Adds a finished passage recorded elsewhere -- another logbook's export --
83
+ // with its track, instrument readings and engine/sail periods. All or nothing.
84
+ // Unlike anything detection writes, its `closed_by` stays empty, so a departure
85
+ // soon after it never reopens it. Without a `distance`, the passage's is the
86
+ // sum over its track, as it is for one logged live.
87
+ function importPassage(db, input, { placeMatchRadius, now }) {
88
+ const { startTime, endTime } = input;
89
+ if (endTime < startTime) {
90
+ throw badRequest('endTime must not be earlier than startTime');
91
+ }
92
+ input.trackPoints.forEach((point, index) =>
93
+ requireWithin(startTime, endTime, point.time, `trackPoints[${index}].time`)
94
+ );
95
+ input.observations.forEach((observation, index) =>
96
+ requireWithin(startTime, endTime, observation.time, `observations[${index}].time`)
97
+ );
98
+ input.propulsion.forEach((segment, index) => {
99
+ requireWithin(startTime, endTime, segment.startTime, `propulsion[${index}].startTime`);
100
+ requireWithin(startTime, endTime, segment.endTime, `propulsion[${index}].endTime`);
101
+ if (segment.endTime < segment.startTime) {
102
+ throw badRequest(`propulsion[${index}].endTime must not be earlier than its startTime`);
103
+ }
104
+ });
105
+
106
+ return withTransaction(db, () => {
107
+ requireNoOverlap(db, startTime, endTime);
108
+
109
+ const start = resolvePlace(
110
+ db,
111
+ input.startPosition,
112
+ input.startPlaceName,
113
+ placeMatchRadius,
114
+ now
115
+ );
116
+ const end = resolvePlace(db, input.endPosition, input.endPlaceName, placeMatchRadius, now);
117
+ const { lastInsertRowid } = db
118
+ .prepare(
119
+ `INSERT INTO log_entries (
120
+ state, start_time, end_time, start_lat, start_lon, end_lat, end_lon,
121
+ start_place_id, end_place_id, start_place_name, end_place_name,
122
+ start_place_pending, end_place_pending, distance, start_tanks, start_batteries, created_at,
123
+ updated_at
124
+ ) VALUES ('closed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
125
+ )
126
+ .run(
127
+ startTime,
128
+ endTime,
129
+ input.startPosition?.lat ?? null,
130
+ input.startPosition?.lon ?? null,
131
+ input.endPosition?.lat ?? null,
132
+ input.endPosition?.lon ?? null,
133
+ start.id,
134
+ end.id,
135
+ start.name,
136
+ end.name,
137
+ start.pending,
138
+ end.pending,
139
+ input.distance ?? trackDistance(input.trackPoints),
140
+ input.startTanks.length > 0 ? JSON.stringify(input.startTanks) : null,
141
+ input.startBatteries.length > 0 ? JSON.stringify(input.startBatteries) : null,
142
+ now,
143
+ now
144
+ );
145
+ const entryId = Number(lastInsertRowid);
146
+
147
+ const insertPoint = db.prepare(
148
+ `INSERT INTO track_points (entry_id, time, lat, lon, ${TRACK_READINGS.join(', ')})
149
+ VALUES (?, ?, ?, ?, ${TRACK_READINGS.map(() => '?').join(', ')})`
150
+ );
151
+ for (const point of input.trackPoints) {
152
+ insertPoint.run(
153
+ entryId,
154
+ point.time,
155
+ point.lat,
156
+ point.lon,
157
+ ...TRACK_READINGS.map((reading) => point[reading] ?? null)
158
+ );
159
+ }
160
+
161
+ const insertObservation = db.prepare(
162
+ `INSERT INTO observations (
163
+ entry_id, time, reason, lat, lon, ${OBSERVATION_READINGS.map(([column]) => column).join(', ')}
164
+ ) VALUES (?, ?, ?, ?, ?, ${OBSERVATION_READINGS.map(() => '?').join(', ')})`
165
+ );
166
+ for (const observation of input.observations) {
167
+ insertObservation.run(
168
+ entryId,
169
+ observation.time,
170
+ observation.reason,
171
+ observation.position?.lat ?? null,
172
+ observation.position?.lon ?? null,
173
+ ...OBSERVATION_READINGS.map(([, field]) => observation[field] ?? null)
174
+ );
175
+ }
176
+
177
+ const insertSegment = db.prepare(
178
+ `INSERT INTO propulsion_segments (entry_id, type, start_time, end_time, source)
179
+ VALUES (?, ?, ?, ?, 'auto')`
180
+ );
181
+ for (const segment of input.propulsion) {
182
+ insertSegment.run(entryId, segment.type, segment.startTime, segment.endTime);
183
+ }
184
+ recomputeDurations(db, entryId, now);
185
+
186
+ return getEntry(db, entryId);
187
+ });
188
+ }
189
+
190
+ module.exports = { importPassage, TRACK_READINGS, OBSERVATION_READINGS };