signalk-chiplog 1.0.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 +52 -0
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/index.js +327 -0
- package/lib/api.js +427 -0
- package/lib/database.js +264 -0
- package/lib/detection.js +407 -0
- package/lib/entries.js +345 -0
- package/lib/errors.js +22 -0
- package/lib/event-watcher.js +332 -0
- package/lib/events.js +287 -0
- package/lib/export.js +220 -0
- package/lib/formats.js +215 -0
- package/lib/manoeuvre-types.js +84 -0
- package/lib/observation-recorder.js +152 -0
- package/lib/place-names.js +208 -0
- package/lib/places.js +128 -0
- package/lib/propulsion-detector.js +156 -0
- package/lib/propulsion.js +75 -0
- package/lib/rows.js +15 -0
- package/lib/track-recorder.js +149 -0
- package/lib/track.js +58 -0
- package/lib/usb-scheduler.js +131 -0
- package/lib/validation.js +105 -0
- package/package.json +67 -0
- package/public/app.css +603 -0
- package/public/entry/entry.css +528 -0
- package/public/entry/icons/apple-touch-icon.png +0 -0
- package/public/entry/icons/icon-192.png +0 -0
- package/public/entry/icons/icon-512.png +0 -0
- package/public/entry/index.html +22 -0
- package/public/entry/js/access.mjs +116 -0
- package/public/entry/js/clock.mjs +29 -0
- package/public/entry/js/components/AccessGate.mjs +70 -0
- package/public/entry/js/components/Dialogs.mjs +64 -0
- package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
- package/public/entry/js/components/NotePanel.mjs +34 -0
- package/public/entry/js/components/RecentList.mjs +113 -0
- package/public/entry/js/components/SketchPanel.mjs +181 -0
- package/public/entry/js/components/StatusHeader.mjs +69 -0
- package/public/entry/js/journal.mjs +139 -0
- package/public/entry/js/main.mjs +360 -0
- package/public/entry/js/outbox.mjs +124 -0
- package/public/entry/js/strokes.mjs +81 -0
- package/public/entry/manifest.webmanifest +26 -0
- package/public/entry/sw.js +75 -0
- package/public/icon.svg +7 -0
- package/public/index.html +18 -0
- package/public/js/api.mjs +85 -0
- package/public/js/auth.mjs +41 -0
- package/public/js/components/ExportView.mjs +182 -0
- package/public/js/components/LogView.mjs +132 -0
- package/public/js/components/PassageView.mjs +343 -0
- package/public/js/components/PropulsionStrip.mjs +60 -0
- package/public/js/components/StatusBar.mjs +55 -0
- package/public/js/components/Timeline.mjs +226 -0
- package/public/js/components/TrackMap.mjs +85 -0
- package/public/js/components/common.mjs +70 -0
- package/public/js/context.mjs +56 -0
- package/public/js/days.mjs +56 -0
- package/public/js/format.mjs +84 -0
- package/public/js/i18n.mjs +480 -0
- package/public/js/ids.mjs +10 -0
- package/public/js/main.mjs +48 -0
- package/public/js/status.mjs +21 -0
- package/public/vendor/htm-LICENSE +202 -0
- package/public/vendor/leaflet/LICENSE +26 -0
- package/public/vendor/leaflet/images/layers-2x.png +0 -0
- package/public/vendor/leaflet/images/layers.png +0 -0
- package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
- package/public/vendor/leaflet/images/marker-icon.png +0 -0
- package/public/vendor/leaflet/images/marker-shadow.png +0 -0
- package/public/vendor/leaflet/leaflet.css +661 -0
- package/public/vendor/leaflet/leaflet.js +6 -0
- package/public/vendor/preact-LICENSE +21 -0
- package/public/vendor/preact-htm.mjs +1 -0
package/lib/api.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
const { getSchemaVersion } = require('./database');
|
|
2
|
+
const { ApiError, badRequest, conflict } = require('./errors');
|
|
3
|
+
const entries = require('./entries');
|
|
4
|
+
const events = require('./events');
|
|
5
|
+
const { renderExport } = require('./export');
|
|
6
|
+
const { toGeoJson, toGpx } = require('./formats');
|
|
7
|
+
const manoeuvreTypes = require('./manoeuvre-types');
|
|
8
|
+
const places = require('./places');
|
|
9
|
+
const propulsion = require('./propulsion');
|
|
10
|
+
const track = require('./track');
|
|
11
|
+
const v = require('./validation');
|
|
12
|
+
|
|
13
|
+
const SQLITE_CONSTRAINT = 19;
|
|
14
|
+
|
|
15
|
+
// Servers predating router.access() only support admin-only plugin routes.
|
|
16
|
+
// Falling back keeps the plugin usable there, at the cost of an admin login.
|
|
17
|
+
function scoped(router, level) {
|
|
18
|
+
return typeof router.access === 'function' ? router.access(level) : router;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sendError(res, err, logError) {
|
|
22
|
+
if (err instanceof ApiError) {
|
|
23
|
+
res.status(err.status).json({ error: { code: err.code, message: err.message } });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
// Extended SQLite codes carry the primary code in their low byte.
|
|
27
|
+
if (err.code === 'ERR_SQLITE_ERROR' && (err.errcode & 0xff) === SQLITE_CONSTRAINT) {
|
|
28
|
+
res.status(409).json({ error: { code: 'constraint_violation', message: err.message } });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
logError(err);
|
|
32
|
+
res
|
|
33
|
+
.status(500)
|
|
34
|
+
.json({ error: { code: 'internal_error', message: 'Unexpected error, see the server log' } });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function optional(body, field, parse) {
|
|
38
|
+
return field in body ? parse(body[field], field) : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withoutUndefined(object) {
|
|
42
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parsePayload(value, name) {
|
|
46
|
+
if (value !== null && !v.isPlainObject(value)) {
|
|
47
|
+
throw badRequest(`${name} must be a JSON object or null`);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseBoolean(value, name) {
|
|
53
|
+
if (typeof value !== 'boolean') {
|
|
54
|
+
throw badRequest(`${name} must be a boolean`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseInteger(value, name) {
|
|
60
|
+
if (!Number.isInteger(value)) {
|
|
61
|
+
throw badRequest(`${name} must be an integer`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const nullableString = (maxLength) => (value, name) =>
|
|
67
|
+
v.parseString(value, name, { maxLength, allowNull: true });
|
|
68
|
+
|
|
69
|
+
const requiredString = (maxLength) => (value, name) => v.parseString(value, name, { maxLength });
|
|
70
|
+
|
|
71
|
+
function parseRange(query) {
|
|
72
|
+
return {
|
|
73
|
+
from: v.parseOptionalTimestamp(query.from, 'from'),
|
|
74
|
+
to: v.parseOptionalTimestamp(query.to, 'to')
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function registerRoutes(router, { getContext, logError }) {
|
|
79
|
+
const readonly = scoped(router, 'readonly');
|
|
80
|
+
const readwrite = scoped(router, 'readwrite');
|
|
81
|
+
const admin = router;
|
|
82
|
+
|
|
83
|
+
const handle = (fn) => async (req, res) => {
|
|
84
|
+
try {
|
|
85
|
+
const body = await fn(getContext(), req, res);
|
|
86
|
+
if (!res.headersSent) {
|
|
87
|
+
if (body === undefined) {
|
|
88
|
+
res.status(204).end();
|
|
89
|
+
} else {
|
|
90
|
+
res.json(body);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
sendError(res, err, logError);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const entryId = (req) => v.parseId(req.params.id);
|
|
99
|
+
|
|
100
|
+
readonly.get(
|
|
101
|
+
'/api/state',
|
|
102
|
+
handle(({ db, detection }) => {
|
|
103
|
+
const { mode, motion, propulsion: under, stateIssue } = detection();
|
|
104
|
+
return {
|
|
105
|
+
activeEntryId:
|
|
106
|
+
db.prepare("SELECT id FROM log_entries WHERE state = 'active'").get()?.id ?? null,
|
|
107
|
+
detection: mode,
|
|
108
|
+
motion,
|
|
109
|
+
propulsion: under,
|
|
110
|
+
stateIssue,
|
|
111
|
+
schemaVersion: getSchemaVersion(db)
|
|
112
|
+
};
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Entries
|
|
117
|
+
|
|
118
|
+
readonly.get(
|
|
119
|
+
'/api/entries',
|
|
120
|
+
handle(({ db }, req) =>
|
|
121
|
+
entries.listEntries(db, { ...parseRange(req.query), ...v.parsePagination(req.query) })
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
readonly.get(
|
|
126
|
+
'/api/entries/:id',
|
|
127
|
+
handle(({ db }, req) => entries.getEntry(db, entryId(req)))
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
readwrite.patch(
|
|
131
|
+
'/api/entries/:id',
|
|
132
|
+
handle(({ db, config, now }, req) => {
|
|
133
|
+
const id = entryId(req);
|
|
134
|
+
const body = v.requireBody(req.body, [
|
|
135
|
+
'startTime',
|
|
136
|
+
'endTime',
|
|
137
|
+
'startPosition',
|
|
138
|
+
'endPosition',
|
|
139
|
+
'startPlaceName',
|
|
140
|
+
'endPlaceName',
|
|
141
|
+
'distance'
|
|
142
|
+
]);
|
|
143
|
+
const patch = {
|
|
144
|
+
startTime: optional(body, 'startTime', v.parseTimestamp),
|
|
145
|
+
endTime: optional(body, 'endTime', v.parseTimestamp),
|
|
146
|
+
startPosition: optional(body, 'startPosition', v.parsePosition),
|
|
147
|
+
endPosition: optional(body, 'endPosition', v.parsePosition),
|
|
148
|
+
startPlaceName: optional(body, 'startPlaceName', nullableString(200)),
|
|
149
|
+
endPlaceName: optional(body, 'endPlaceName', nullableString(200)),
|
|
150
|
+
distance: optional(body, 'distance', v.parseNonNegativeNumber)
|
|
151
|
+
};
|
|
152
|
+
return entries.updateEntry(db, id, patch, {
|
|
153
|
+
placeMatchRadius: config.placeMatchRadius,
|
|
154
|
+
now: now()
|
|
155
|
+
});
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
readwrite.post(
|
|
160
|
+
'/api/entries/:id/close',
|
|
161
|
+
handle(({ db, config, now, vesselPosition }, req) =>
|
|
162
|
+
entries.closeEntry(db, entryId(req), {
|
|
163
|
+
now: now(),
|
|
164
|
+
position: vesselPosition(),
|
|
165
|
+
placeMatchRadius: config.placeMatchRadius
|
|
166
|
+
})
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
readwrite.post(
|
|
171
|
+
'/api/entries/:id/merge',
|
|
172
|
+
handle(({ db, now }, req) => {
|
|
173
|
+
const id = entryId(req);
|
|
174
|
+
const body = v.requireBody(req.body, ['withEntryId']);
|
|
175
|
+
return entries.mergeEntries(db, id, v.parseId(body.withEntryId, 'withEntryId'), now());
|
|
176
|
+
})
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
admin.delete(
|
|
180
|
+
'/api/entries/:id',
|
|
181
|
+
handle(({ db }, req) => {
|
|
182
|
+
entries.deleteEntry(db, entryId(req));
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Track, observations, propulsion
|
|
187
|
+
|
|
188
|
+
readonly.get(
|
|
189
|
+
'/api/entries/:id/track',
|
|
190
|
+
handle(({ db }, req, res) => {
|
|
191
|
+
const id = entryId(req);
|
|
192
|
+
const format = v.parseEnum(req.query.format ?? 'geojson', 'format', ['geojson', 'gpx']);
|
|
193
|
+
const entry = entries.getEntry(db, id);
|
|
194
|
+
const trackPoints = track.allTrackPoints(db, id);
|
|
195
|
+
if (format === 'gpx') {
|
|
196
|
+
res.type('application/gpx+xml').send(toGpx([{ entry, trackPoints }]));
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return toGeoJson(entry, trackPoints);
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
readonly.get(
|
|
204
|
+
'/api/entries/:id/observations',
|
|
205
|
+
handle(({ db }, req) => track.listObservations(db, entryId(req), v.parsePagination(req.query)))
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
readonly.get(
|
|
209
|
+
'/api/entries/:id/propulsion',
|
|
210
|
+
handle(({ db }, req) => propulsion.listSegments(db, entryId(req), v.parsePagination(req.query)))
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
readwrite.patch(
|
|
214
|
+
'/api/propulsion/:id',
|
|
215
|
+
handle(({ db, now }, req) => {
|
|
216
|
+
const id = v.parseId(req.params.id);
|
|
217
|
+
const body = v.requireBody(req.body, ['type']);
|
|
218
|
+
const type = v.parseEnum(body.type, 'type', ['engine', 'sail']);
|
|
219
|
+
return propulsion.correctSegment(db, id, { type }, { now: now() });
|
|
220
|
+
})
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Events
|
|
224
|
+
|
|
225
|
+
readonly.get(
|
|
226
|
+
'/api/entries/:id/events',
|
|
227
|
+
handle(({ db }, req) =>
|
|
228
|
+
events.listEvents(db, entryId(req), {
|
|
229
|
+
type:
|
|
230
|
+
req.query.type === undefined
|
|
231
|
+
? undefined
|
|
232
|
+
: v.parseEnum(req.query.type, 'type', events.EVENT_TYPES),
|
|
233
|
+
...v.parsePagination(req.query)
|
|
234
|
+
})
|
|
235
|
+
)
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const EVENT_FIELDS = ['type', 'subtype', 'comment', 'payload', 'time', 'position', 'clientRef'];
|
|
239
|
+
|
|
240
|
+
const parseEventInput = (body) => ({
|
|
241
|
+
type: v.parseEnum(body.type, 'type', events.CLIENT_EVENT_TYPES),
|
|
242
|
+
subtype: optional(body, 'subtype', nullableString(200)),
|
|
243
|
+
comment: optional(body, 'comment', nullableString(10000)),
|
|
244
|
+
payload: optional(body, 'payload', parsePayload),
|
|
245
|
+
time: optional(body, 'time', v.parseTimestamp),
|
|
246
|
+
position: optional(body, 'position', v.parsePosition),
|
|
247
|
+
clientRef: optional(body, 'clientRef', requiredString(100))
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Conditions at a manoeuvre belong in the log — but only as it happens;
|
|
251
|
+
// readings now say nothing about a manoeuvre logged after the fact.
|
|
252
|
+
const observeLiveManoeuvre = (observeEvent, input, { event, created }) => {
|
|
253
|
+
if (created && event.type === 'manoeuvre' && input.time === undefined) {
|
|
254
|
+
observeEvent(event.entryId, event.time);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
readwrite.post(
|
|
259
|
+
'/api/entries/:id/events',
|
|
260
|
+
handle(({ db, now, vesselPosition, observeEvent }, req, res) => {
|
|
261
|
+
const id = entryId(req);
|
|
262
|
+
const input = parseEventInput(v.requireBody(req.body, EVENT_FIELDS));
|
|
263
|
+
const outcome = events.createEvent(db, id, input, {
|
|
264
|
+
now: now(),
|
|
265
|
+
vesselPosition: vesselPosition()
|
|
266
|
+
});
|
|
267
|
+
observeLiveManoeuvre(observeEvent, input, outcome);
|
|
268
|
+
res.status(outcome.created ? 201 : 200);
|
|
269
|
+
return outcome.event;
|
|
270
|
+
})
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// What the tablet posts: the server finds the passage the entry belongs to.
|
|
274
|
+
readwrite.post(
|
|
275
|
+
'/api/events',
|
|
276
|
+
handle(({ db, config, now, vesselPosition, observeEvent }, req, res) => {
|
|
277
|
+
const input = parseEventInput(v.requireBody(req.body, EVENT_FIELDS));
|
|
278
|
+
const outcome = events.logCrewEvent(db, input, {
|
|
279
|
+
now: now(),
|
|
280
|
+
vesselPosition: vesselPosition(),
|
|
281
|
+
placeMatchRadius: config.placeMatchRadius
|
|
282
|
+
});
|
|
283
|
+
observeLiveManoeuvre(observeEvent, input, outcome);
|
|
284
|
+
res.status(outcome.created ? 201 : 200);
|
|
285
|
+
return { ...outcome.event, openedEntry: outcome.openedEntry };
|
|
286
|
+
})
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
readwrite.patch(
|
|
290
|
+
'/api/events/:id',
|
|
291
|
+
handle(({ db }, req) => {
|
|
292
|
+
const id = v.parseId(req.params.id);
|
|
293
|
+
const body = v.requireBody(req.body, ['time', 'comment', 'subtype', 'payload']);
|
|
294
|
+
const patch = withoutUndefined({
|
|
295
|
+
time: optional(body, 'time', v.parseTimestamp),
|
|
296
|
+
comment: optional(body, 'comment', nullableString(10000)),
|
|
297
|
+
subtype: optional(body, 'subtype', nullableString(200)),
|
|
298
|
+
payload: optional(body, 'payload', parsePayload)
|
|
299
|
+
});
|
|
300
|
+
return events.updateEvent(db, id, patch);
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
readwrite.delete(
|
|
305
|
+
'/api/events/:id',
|
|
306
|
+
handle(({ db }, req) => {
|
|
307
|
+
events.deleteEvent(db, v.parseId(req.params.id));
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// Places
|
|
312
|
+
|
|
313
|
+
readonly.get(
|
|
314
|
+
'/api/places',
|
|
315
|
+
handle(({ db }, req) => places.listPlaces(db, v.parsePagination(req.query)))
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
readwrite.patch(
|
|
319
|
+
'/api/places/:id',
|
|
320
|
+
handle(({ db, now }, req) => {
|
|
321
|
+
const id = v.parseId(req.params.id);
|
|
322
|
+
const body = v.requireBody(req.body, ['name']);
|
|
323
|
+
return places.renamePlace(
|
|
324
|
+
db,
|
|
325
|
+
id,
|
|
326
|
+
v.parseString(body.name, 'name', { maxLength: 200 }),
|
|
327
|
+
now()
|
|
328
|
+
);
|
|
329
|
+
})
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
admin.delete(
|
|
333
|
+
'/api/places/:id',
|
|
334
|
+
handle(({ db }, req) => {
|
|
335
|
+
places.deletePlace(db, v.parseId(req.params.id));
|
|
336
|
+
})
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// Manoeuvre shortcuts
|
|
340
|
+
|
|
341
|
+
readonly.get(
|
|
342
|
+
'/api/manoeuvre-types',
|
|
343
|
+
handle(({ db }, req) => manoeuvreTypes.listManoeuvreTypes(db, v.parsePagination(req.query)))
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
admin.post(
|
|
347
|
+
'/api/manoeuvre-types',
|
|
348
|
+
handle(({ db }, req, res) => {
|
|
349
|
+
const body = v.requireBody(req.body, ['key', 'label', 'icon', 'sortOrder', 'enabled']);
|
|
350
|
+
const type = manoeuvreTypes.createManoeuvreType(db, {
|
|
351
|
+
key: v.parseString(body.key, 'key', { maxLength: 40 }),
|
|
352
|
+
label: v.parseString(body.label, 'label', { maxLength: 100 }),
|
|
353
|
+
icon: optional(body, 'icon', nullableString(100)),
|
|
354
|
+
sortOrder: optional(body, 'sortOrder', parseInteger),
|
|
355
|
+
enabled: optional(body, 'enabled', parseBoolean)
|
|
356
|
+
});
|
|
357
|
+
res.status(201);
|
|
358
|
+
return type;
|
|
359
|
+
})
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
admin.patch(
|
|
363
|
+
'/api/manoeuvre-types/:key',
|
|
364
|
+
handle(({ db }, req) => {
|
|
365
|
+
const body = v.requireBody(req.body, ['label', 'icon', 'sortOrder', 'enabled']);
|
|
366
|
+
return manoeuvreTypes.updateManoeuvreType(db, req.params.key, {
|
|
367
|
+
label: optional(body, 'label', requiredString(100)),
|
|
368
|
+
icon: optional(body, 'icon', nullableString(100)),
|
|
369
|
+
sortOrder: optional(body, 'sortOrder', parseInteger),
|
|
370
|
+
enabled: optional(body, 'enabled', parseBoolean)
|
|
371
|
+
});
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
admin.delete(
|
|
376
|
+
'/api/manoeuvre-types/:key',
|
|
377
|
+
handle(({ db }, req) => {
|
|
378
|
+
manoeuvreTypes.deleteManoeuvreType(db, req.params.key);
|
|
379
|
+
})
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Export
|
|
383
|
+
|
|
384
|
+
readonly.get(
|
|
385
|
+
'/api/export',
|
|
386
|
+
handle(({ db, now }, req, res) => {
|
|
387
|
+
const format = v.parseEnum(req.query.format ?? 'json', 'format', [
|
|
388
|
+
'json',
|
|
389
|
+
'csv',
|
|
390
|
+
'gpx',
|
|
391
|
+
'pdf'
|
|
392
|
+
]);
|
|
393
|
+
if (format === 'pdf') {
|
|
394
|
+
throw new ApiError(501, 'not_implemented', 'PDF export arrives in V1.1');
|
|
395
|
+
}
|
|
396
|
+
const { contentType, filename, body } = renderExport(
|
|
397
|
+
db,
|
|
398
|
+
format,
|
|
399
|
+
parseRange(req.query),
|
|
400
|
+
now()
|
|
401
|
+
);
|
|
402
|
+
res.attachment(filename).type(contentType).send(body);
|
|
403
|
+
return null;
|
|
404
|
+
})
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
readonly.get(
|
|
408
|
+
'/api/export/usb',
|
|
409
|
+
handle(({ config, usbExport }) => ({ directory: config.usbExportPath, ...usbExport.status() }))
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// Goes through the scheduler, so it never overlaps an automatic copy.
|
|
413
|
+
admin.post(
|
|
414
|
+
'/api/export/usb',
|
|
415
|
+
handle(({ config, usbExport }) => {
|
|
416
|
+
if (!config.usbExportPath) {
|
|
417
|
+
throw conflict(
|
|
418
|
+
'usb_export_not_configured',
|
|
419
|
+
'Set the USB export directory in the plugin configuration first'
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
return usbExport.run('manual');
|
|
423
|
+
})
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
module.exports = { registerRoutes };
|
package/lib/database.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
3
|
+
|
|
4
|
+
const DATABASE_FILENAME = 'chiplog.sqlite';
|
|
5
|
+
|
|
6
|
+
// Manoeuvre shortcuts shipped with the plugin (SPEC §4.3). Users may disable or
|
|
7
|
+
// reorder them and add their own; a later migration adds new built-in ones.
|
|
8
|
+
const BUILTIN_MANOEUVRE_TYPES = [
|
|
9
|
+
['tack', 'Tack', 10],
|
|
10
|
+
['gybe', 'Gybe', 20],
|
|
11
|
+
['reef_in', 'Reef in', 30],
|
|
12
|
+
['reef_out', 'Shake out reef', 40],
|
|
13
|
+
['sail_change', 'Sail change', 50],
|
|
14
|
+
['anchor_down', 'Anchor down', 60],
|
|
15
|
+
['anchor_up', 'Anchor up', 70],
|
|
16
|
+
['moor', 'Moor', 80],
|
|
17
|
+
['cast_off', 'Cast off', 90],
|
|
18
|
+
['watch_change', 'Watch change', 100]
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Append-only: each entry is applied once, in order, and its index becomes the
|
|
22
|
+
// database's user_version. Never edit or reorder an already-released migration.
|
|
23
|
+
const MIGRATIONS = [
|
|
24
|
+
`
|
|
25
|
+
-- Units follow Signal K: angles in radians, speeds in m/s, distances in
|
|
26
|
+
-- metres, durations in seconds, pressure in Pa, temperatures in K.
|
|
27
|
+
-- Timestamps are ISO 8601 UTC text with millisecond precision.
|
|
28
|
+
|
|
29
|
+
CREATE TABLE places (
|
|
30
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
31
|
+
name TEXT NOT NULL,
|
|
32
|
+
lat REAL NOT NULL,
|
|
33
|
+
lon REAL NOT NULL,
|
|
34
|
+
source TEXT NOT NULL CHECK (source IN ('geocoding', 'manual')),
|
|
35
|
+
created_at TEXT NOT NULL,
|
|
36
|
+
updated_at TEXT NOT NULL
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
-- Supports the bounding-box prefilter of the radius match; the exact
|
|
40
|
+
-- distance test then runs in application code.
|
|
41
|
+
CREATE INDEX idx_places_position ON places (lat, lon);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE log_entries (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'closed')),
|
|
46
|
+
start_time TEXT NOT NULL,
|
|
47
|
+
end_time TEXT,
|
|
48
|
+
-- Set when the vessel stops, cleared if it moves again before the
|
|
49
|
+
-- configured tolerance elapses (SPEC §3.1).
|
|
50
|
+
stopped_since TEXT,
|
|
51
|
+
start_lat REAL,
|
|
52
|
+
start_lon REAL,
|
|
53
|
+
end_lat REAL,
|
|
54
|
+
end_lon REAL,
|
|
55
|
+
start_place_id INTEGER REFERENCES places (id) ON DELETE SET NULL,
|
|
56
|
+
end_place_id INTEGER REFERENCES places (id) ON DELETE SET NULL,
|
|
57
|
+
-- Denormalised on purpose: the logbook records the name as it stood at the
|
|
58
|
+
-- time, so renaming a place later must not rewrite past entries.
|
|
59
|
+
start_place_name TEXT,
|
|
60
|
+
end_place_name TEXT,
|
|
61
|
+
distance REAL NOT NULL DEFAULT 0,
|
|
62
|
+
engine_duration INTEGER NOT NULL DEFAULT 0,
|
|
63
|
+
sail_duration INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
created_at TEXT NOT NULL,
|
|
65
|
+
updated_at TEXT NOT NULL,
|
|
66
|
+
CHECK (end_time IS NULL OR end_time >= start_time),
|
|
67
|
+
CHECK (state = 'active' OR end_time IS NOT NULL)
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE INDEX idx_log_entries_start_time ON log_entries (start_time);
|
|
71
|
+
|
|
72
|
+
-- One vessel per instance, passages are sequential: at most one open entry.
|
|
73
|
+
CREATE UNIQUE INDEX idx_log_entries_single_active
|
|
74
|
+
ON log_entries (state) WHERE state = 'active';
|
|
75
|
+
|
|
76
|
+
-- Dense geometry for the map and the GPX export (SPEC §4.1). Deliberately
|
|
77
|
+
-- narrow: instrument readings live in observations instead.
|
|
78
|
+
CREATE TABLE track_points (
|
|
79
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
80
|
+
entry_id INTEGER NOT NULL REFERENCES log_entries (id) ON DELETE CASCADE,
|
|
81
|
+
time TEXT NOT NULL,
|
|
82
|
+
lat REAL NOT NULL,
|
|
83
|
+
lon REAL NOT NULL,
|
|
84
|
+
sog REAL,
|
|
85
|
+
cog REAL
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE INDEX idx_track_points_entry_time ON track_points (entry_id, time);
|
|
89
|
+
|
|
90
|
+
-- Sparse instrument snapshots: the rows the facsimile PDF renders as
|
|
91
|
+
-- logbook lines (SPEC §4.5). Every reading is nullable, since a boat may
|
|
92
|
+
-- lack any given sensor (SPEC §4.7).
|
|
93
|
+
CREATE TABLE observations (
|
|
94
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
95
|
+
entry_id INTEGER NOT NULL REFERENCES log_entries (id) ON DELETE CASCADE,
|
|
96
|
+
time TEXT NOT NULL,
|
|
97
|
+
reason TEXT NOT NULL CHECK (reason IN ('periodic', 'entry_start', 'entry_end', 'event')),
|
|
98
|
+
lat REAL,
|
|
99
|
+
lon REAL,
|
|
100
|
+
sog REAL,
|
|
101
|
+
cog REAL,
|
|
102
|
+
heading REAL,
|
|
103
|
+
stw REAL,
|
|
104
|
+
twd REAL,
|
|
105
|
+
tws REAL,
|
|
106
|
+
awa REAL,
|
|
107
|
+
aws REAL,
|
|
108
|
+
depth REAL,
|
|
109
|
+
pressure REAL,
|
|
110
|
+
air_temp REAL,
|
|
111
|
+
water_temp REAL,
|
|
112
|
+
trip_log REAL,
|
|
113
|
+
engine_runtime REAL
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
CREATE INDEX idx_observations_entry_time ON observations (entry_id, time);
|
|
117
|
+
|
|
118
|
+
CREATE TABLE propulsion_segments (
|
|
119
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
120
|
+
entry_id INTEGER NOT NULL REFERENCES log_entries (id) ON DELETE CASCADE,
|
|
121
|
+
type TEXT NOT NULL CHECK (type IN ('engine', 'sail')),
|
|
122
|
+
start_time TEXT NOT NULL,
|
|
123
|
+
end_time TEXT,
|
|
124
|
+
-- 'manual' once a user correction has overridden the detection (SPEC §4.2).
|
|
125
|
+
source TEXT NOT NULL DEFAULT 'auto' CHECK (source IN ('auto', 'manual')),
|
|
126
|
+
average_rpm REAL,
|
|
127
|
+
CHECK (end_time IS NULL OR end_time >= start_time)
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
CREATE INDEX idx_propulsion_segments_entry_time
|
|
131
|
+
ON propulsion_segments (entry_id, start_time);
|
|
132
|
+
|
|
133
|
+
CREATE TABLE events (
|
|
134
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
135
|
+
entry_id INTEGER NOT NULL REFERENCES log_entries (id) ON DELETE CASCADE,
|
|
136
|
+
time TEXT NOT NULL,
|
|
137
|
+
type TEXT NOT NULL CHECK (
|
|
138
|
+
type IN (
|
|
139
|
+
'manoeuvre',
|
|
140
|
+
'text_annotation',
|
|
141
|
+
'handwritten_annotation',
|
|
142
|
+
'sk_alarm',
|
|
143
|
+
'autopilot',
|
|
144
|
+
'weather_threshold',
|
|
145
|
+
'manual_correction'
|
|
146
|
+
)
|
|
147
|
+
),
|
|
148
|
+
-- Qualifies the type: manoeuvre key, Signal K notification path, autopilot
|
|
149
|
+
-- state. Intentionally not a foreign key, so history survives a manoeuvre
|
|
150
|
+
-- type being deleted.
|
|
151
|
+
subtype TEXT,
|
|
152
|
+
lat REAL,
|
|
153
|
+
lon REAL,
|
|
154
|
+
comment TEXT,
|
|
155
|
+
-- JSON, for structured detail only: handwritten strokes, sail selection,
|
|
156
|
+
-- the before/after of a correction.
|
|
157
|
+
payload TEXT,
|
|
158
|
+
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('auto', 'manual')),
|
|
159
|
+
created_at TEXT NOT NULL
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
CREATE INDEX idx_events_entry_time ON events (entry_id, time);
|
|
163
|
+
CREATE INDEX idx_events_type ON events (type);
|
|
164
|
+
|
|
165
|
+
CREATE TABLE manoeuvre_types (
|
|
166
|
+
key TEXT PRIMARY KEY,
|
|
167
|
+
label TEXT NOT NULL,
|
|
168
|
+
icon TEXT,
|
|
169
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
170
|
+
builtin INTEGER NOT NULL DEFAULT 0 CHECK (builtin IN (0, 1)),
|
|
171
|
+
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1))
|
|
172
|
+
);
|
|
173
|
+
`,
|
|
174
|
+
`
|
|
175
|
+
-- Last time passage detection saw the vessel moving, refreshed about once a
|
|
176
|
+
-- minute while under way. After a restart it dates the end of a passage that
|
|
177
|
+
-- stopped while the plugin was not running.
|
|
178
|
+
ALTER TABLE log_entries ADD COLUMN last_moving_at TEXT;
|
|
179
|
+
`,
|
|
180
|
+
`
|
|
181
|
+
-- A place name generated from coordinates while online geocoding has not
|
|
182
|
+
-- yet answered for that departure or arrival. Cleared once resolved, or as
|
|
183
|
+
-- soon as someone types a name.
|
|
184
|
+
ALTER TABLE log_entries ADD COLUMN start_place_pending INTEGER NOT NULL DEFAULT 0
|
|
185
|
+
CHECK (start_place_pending IN (0, 1));
|
|
186
|
+
ALTER TABLE log_entries ADD COLUMN end_place_pending INTEGER NOT NULL DEFAULT 0
|
|
187
|
+
CHECK (end_place_pending IN (0, 1));
|
|
188
|
+
`,
|
|
189
|
+
`
|
|
190
|
+
-- The departure manoeuvre (cast off, anchor up) that opened this passage by
|
|
191
|
+
-- hand before the vessel moved. Undoing that manoeuvre before any movement
|
|
192
|
+
-- removes the passage with it.
|
|
193
|
+
ALTER TABLE log_entries ADD COLUMN opened_by_event_id INTEGER
|
|
194
|
+
REFERENCES events (id) ON DELETE SET NULL;
|
|
195
|
+
|
|
196
|
+
-- Idempotency key chosen by the client, so an entry replayed from the
|
|
197
|
+
-- tablet's offline queue after a lost response is not logged twice.
|
|
198
|
+
ALTER TABLE events ADD COLUMN client_ref TEXT;
|
|
199
|
+
CREATE UNIQUE INDEX idx_events_client_ref ON events (client_ref)
|
|
200
|
+
WHERE client_ref IS NOT NULL;
|
|
201
|
+
|
|
202
|
+
-- Finds the vessel's position at the time of an entry logged after the fact,
|
|
203
|
+
-- before knowing which passage it belongs to.
|
|
204
|
+
CREATE INDEX idx_track_points_time ON track_points (time);
|
|
205
|
+
`
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
function seedBuiltinManoeuvreTypes(db) {
|
|
209
|
+
const insert = db.prepare(
|
|
210
|
+
`INSERT INTO manoeuvre_types (key, label, sort_order, builtin)
|
|
211
|
+
VALUES (?, ?, ?, 1)
|
|
212
|
+
ON CONFLICT (key) DO NOTHING`
|
|
213
|
+
);
|
|
214
|
+
for (const [key, label, sortOrder] of BUILTIN_MANOEUVRE_TYPES) {
|
|
215
|
+
insert.run(key, label, sortOrder);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function withTransaction(db, fn) {
|
|
220
|
+
db.exec('BEGIN');
|
|
221
|
+
try {
|
|
222
|
+
const result = fn();
|
|
223
|
+
db.exec('COMMIT');
|
|
224
|
+
return result;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
db.exec('ROLLBACK');
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getSchemaVersion(db) {
|
|
232
|
+
return db.prepare('PRAGMA user_version').get().user_version;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function migrate(db) {
|
|
236
|
+
const currentVersion = getSchemaVersion(db);
|
|
237
|
+
|
|
238
|
+
for (let version = currentVersion; version < MIGRATIONS.length; version += 1) {
|
|
239
|
+
withTransaction(db, () => {
|
|
240
|
+
db.exec(MIGRATIONS[version]);
|
|
241
|
+
if (version === 0) {
|
|
242
|
+
seedBuiltinManoeuvreTypes(db);
|
|
243
|
+
}
|
|
244
|
+
db.exec(`PRAGMA user_version = ${version + 1}`);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { from: currentVersion, to: MIGRATIONS.length };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function openDatabase(dataDirPath) {
|
|
252
|
+
const db = new DatabaseSync(path.join(dataDirPath, DATABASE_FILENAME));
|
|
253
|
+
|
|
254
|
+
// WAL keeps reads working while a passage is being written, and survives
|
|
255
|
+
// the abrupt power cuts a boat installation gets.
|
|
256
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
257
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
258
|
+
|
|
259
|
+
const migrated = migrate(db);
|
|
260
|
+
|
|
261
|
+
return { db, migrated };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
module.exports = { openDatabase, withTransaction, getSchemaVersion, DATABASE_FILENAME };
|