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.
Files changed (76) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +290 -0
  4. package/index.js +327 -0
  5. package/lib/api.js +427 -0
  6. package/lib/database.js +264 -0
  7. package/lib/detection.js +407 -0
  8. package/lib/entries.js +345 -0
  9. package/lib/errors.js +22 -0
  10. package/lib/event-watcher.js +332 -0
  11. package/lib/events.js +287 -0
  12. package/lib/export.js +220 -0
  13. package/lib/formats.js +215 -0
  14. package/lib/manoeuvre-types.js +84 -0
  15. package/lib/observation-recorder.js +152 -0
  16. package/lib/place-names.js +208 -0
  17. package/lib/places.js +128 -0
  18. package/lib/propulsion-detector.js +156 -0
  19. package/lib/propulsion.js +75 -0
  20. package/lib/rows.js +15 -0
  21. package/lib/track-recorder.js +149 -0
  22. package/lib/track.js +58 -0
  23. package/lib/usb-scheduler.js +131 -0
  24. package/lib/validation.js +105 -0
  25. package/package.json +67 -0
  26. package/public/app.css +603 -0
  27. package/public/entry/entry.css +528 -0
  28. package/public/entry/icons/apple-touch-icon.png +0 -0
  29. package/public/entry/icons/icon-192.png +0 -0
  30. package/public/entry/icons/icon-512.png +0 -0
  31. package/public/entry/index.html +22 -0
  32. package/public/entry/js/access.mjs +116 -0
  33. package/public/entry/js/clock.mjs +29 -0
  34. package/public/entry/js/components/AccessGate.mjs +70 -0
  35. package/public/entry/js/components/Dialogs.mjs +64 -0
  36. package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
  37. package/public/entry/js/components/NotePanel.mjs +34 -0
  38. package/public/entry/js/components/RecentList.mjs +113 -0
  39. package/public/entry/js/components/SketchPanel.mjs +181 -0
  40. package/public/entry/js/components/StatusHeader.mjs +69 -0
  41. package/public/entry/js/journal.mjs +139 -0
  42. package/public/entry/js/main.mjs +360 -0
  43. package/public/entry/js/outbox.mjs +124 -0
  44. package/public/entry/js/strokes.mjs +81 -0
  45. package/public/entry/manifest.webmanifest +26 -0
  46. package/public/entry/sw.js +75 -0
  47. package/public/icon.svg +7 -0
  48. package/public/index.html +18 -0
  49. package/public/js/api.mjs +85 -0
  50. package/public/js/auth.mjs +41 -0
  51. package/public/js/components/ExportView.mjs +182 -0
  52. package/public/js/components/LogView.mjs +132 -0
  53. package/public/js/components/PassageView.mjs +343 -0
  54. package/public/js/components/PropulsionStrip.mjs +60 -0
  55. package/public/js/components/StatusBar.mjs +55 -0
  56. package/public/js/components/Timeline.mjs +226 -0
  57. package/public/js/components/TrackMap.mjs +85 -0
  58. package/public/js/components/common.mjs +70 -0
  59. package/public/js/context.mjs +56 -0
  60. package/public/js/days.mjs +56 -0
  61. package/public/js/format.mjs +84 -0
  62. package/public/js/i18n.mjs +480 -0
  63. package/public/js/ids.mjs +10 -0
  64. package/public/js/main.mjs +48 -0
  65. package/public/js/status.mjs +21 -0
  66. package/public/vendor/htm-LICENSE +202 -0
  67. package/public/vendor/leaflet/LICENSE +26 -0
  68. package/public/vendor/leaflet/images/layers-2x.png +0 -0
  69. package/public/vendor/leaflet/images/layers.png +0 -0
  70. package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
  71. package/public/vendor/leaflet/images/marker-icon.png +0 -0
  72. package/public/vendor/leaflet/images/marker-shadow.png +0 -0
  73. package/public/vendor/leaflet/leaflet.css +661 -0
  74. package/public/vendor/leaflet/leaflet.js +6 -0
  75. package/public/vendor/preact-LICENSE +21 -0
  76. 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 };
@@ -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 };