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 +33 -1
- package/README.md +60 -18
- package/lib/api.js +154 -0
- package/lib/database.js +13 -0
- package/lib/entries.js +23 -2
- package/lib/passage-import.js +190 -0
- package/lib/place-names.js +73 -20
- package/lib/places.js +1 -0
- package/lib/statistics.js +208 -0
- package/lib/validation.js +8 -0
- package/package.json +2 -1
- package/public/app.css +207 -4
- package/public/js/calendar.mjs +50 -0
- package/public/js/components/AnimationView.mjs +11 -14
- package/public/js/components/DateRangePicker.mjs +187 -0
- package/public/js/components/ExportView.mjs +11 -21
- package/public/js/components/PassageView.mjs +48 -1
- package/public/js/components/ReplayView.mjs +13 -23
- package/public/js/components/StatisticsView.mjs +249 -0
- package/public/js/context.mjs +3 -0
- package/public/js/format.mjs +8 -0
- package/public/js/i18n.mjs +76 -12
- package/public/js/main.mjs +5 -0
- package/public/js/shortcuts.mjs +31 -0
- package/public/js/statistics.mjs +76 -0
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.
|
|
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** —
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
- **
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
|
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 };
|