signalk-chiplog 2.4.0 → 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,16 @@ 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
+
9
19
  ## [2.4.0] - 2026-09-18
10
20
 
11
21
  ### Added
@@ -307,7 +317,8 @@ First release.
307
317
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
308
318
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
309
319
 
310
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v2.4.0...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
311
322
  [2.4.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.1...v2.4.0
312
323
  [2.3.1]: https://github.com/ricard33/signalk-chiplog/compare/v2.3.0...v2.3.1
313
324
  [2.3.0]: https://github.com/ricard33/signalk-chiplog/compare/v2.2.0...v2.3.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-)
@@ -417,6 +418,32 @@ at the time.
417
418
  track has one point per **Track point interval**. Everything else read from a continuously published path — position,
418
419
  speed, wind, engine, autopilot, depth, barometer — is reconstructed the same as live.
419
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
+
420
447
  ## Privacy and online services 🔒
421
448
 
422
449
  - **Place names.** With geocoding on, the position of each departure and arrival that matches no known place is sent to
@@ -549,6 +576,7 @@ npm install # also copies the browser libraries into public/vendor/
549
576
  npm test
550
577
  npm run lint
551
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
552
580
  ```
553
581
 
554
582
  The functional specification is in [docs/SPEC.md](docs/SPEC.md), the data model in
package/lib/api.js CHANGED
@@ -8,6 +8,7 @@ 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');
@@ -81,6 +82,112 @@ function parseRange(query) {
81
82
  };
82
83
  }
83
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
+
84
191
  function registerRoutes(router, { getContext, logError }) {
85
192
  const readonly = scoped(router, 'readonly');
86
193
  const readwrite = scoped(router, 'readwrite');
@@ -143,6 +250,47 @@ function registerRoutes(router, { getContext, logError }) {
143
250
  handle(({ db }, req) => entries.getEntry(db, entryId(req)))
144
251
  );
145
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
+
146
294
  readwrite.patch(
147
295
  '/api/entries/:id',
148
296
  handle(({ db, config, now }, req) => {
@@ -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 };
package/lib/validation.js CHANGED
@@ -44,6 +44,13 @@ function parseNonNegativeNumber(value, name) {
44
44
  return value;
45
45
  }
46
46
 
47
+ function parseNumber(value, name) {
48
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
49
+ throw badRequest(`${name} must be a number`);
50
+ }
51
+ return value;
52
+ }
53
+
47
54
  function parsePosition(value, name) {
48
55
  if (value === null) {
49
56
  return null;
@@ -98,6 +105,7 @@ module.exports = {
98
105
  parseTimestamp,
99
106
  parseOptionalTimestamp,
100
107
  parseNonNegativeNumber,
108
+ parseNumber,
101
109
  parsePosition,
102
110
  parseString,
103
111
  parsePagination,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "Nautical logbook for Signal K: automatic entries, handwritten notes",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -17,6 +17,7 @@
17
17
  "format": "prettier --write .",
18
18
  "format:check": "prettier --check .",
19
19
  "demo:seed": "node scripts/seed-demo.js",
20
+ "import:postgsail": "node scripts/import-postgsail.js",
20
21
  "prepare": "husky && node scripts/vendor.js"
21
22
  },
22
23
  "keywords": [