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 +12 -1
- package/README.md +28 -0
- package/lib/api.js +148 -0
- package/lib/passage-import.js +190 -0
- package/lib/validation.js +8 -0
- package/package.json +2 -1
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.
|
|
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.
|
|
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": [
|