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/events.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
const { withTransaction } = require('./database');
|
|
2
|
+
const { ApiError, badRequest, conflict, notFound } = require('./errors');
|
|
3
|
+
const { activeEntryId, entryNearArrival, openEntryByHand, requireEntryRow } = require('./entries');
|
|
4
|
+
const { toPosition, paginate } = require('./rows');
|
|
5
|
+
const { isPlainObject } = require('./validation');
|
|
6
|
+
|
|
7
|
+
const EVENT_TYPES = [
|
|
8
|
+
'manoeuvre',
|
|
9
|
+
'text_annotation',
|
|
10
|
+
'handwritten_annotation',
|
|
11
|
+
'sk_alarm',
|
|
12
|
+
'autopilot',
|
|
13
|
+
'weather_threshold',
|
|
14
|
+
'manual_correction'
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// The other event types are produced by the plugin itself from Signal K data
|
|
18
|
+
// or from corrections, never posted by a client.
|
|
19
|
+
const CLIENT_EVENT_TYPES = ['manoeuvre', 'text_annotation', 'handwritten_annotation'];
|
|
20
|
+
|
|
21
|
+
// Logged with no passage open, these open one: the crew is leaving.
|
|
22
|
+
const DEPARTURE_MANOEUVRES = new Set(['cast_off', 'anchor_up']);
|
|
23
|
+
|
|
24
|
+
// An entry logged after the fact — typically replayed from the tablet's offline
|
|
25
|
+
// queue — takes the position the track recorded at its time.
|
|
26
|
+
const TRACK_POSITION_WINDOW_MS = 2 * 60 * 1000;
|
|
27
|
+
const CURRENT_POSITION_WINDOW_MS = 5 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
function toEvent(row) {
|
|
30
|
+
return {
|
|
31
|
+
id: row.id,
|
|
32
|
+
entryId: row.entry_id,
|
|
33
|
+
time: row.time,
|
|
34
|
+
type: row.type,
|
|
35
|
+
subtype: row.subtype,
|
|
36
|
+
position: toPosition(row.lat, row.lon),
|
|
37
|
+
comment: row.comment,
|
|
38
|
+
payload: row.payload === null ? null : JSON.parse(row.payload),
|
|
39
|
+
source: row.source,
|
|
40
|
+
clientRef: row.client_ref,
|
|
41
|
+
createdAt: row.created_at
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function requireEventRow(db, id) {
|
|
46
|
+
const row = db.prepare('SELECT * FROM events WHERE id = ?').get(id);
|
|
47
|
+
if (!row) {
|
|
48
|
+
throw notFound('event', id);
|
|
49
|
+
}
|
|
50
|
+
return row;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isFiniteNumber(value) {
|
|
54
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isValidStrokes(payload) {
|
|
58
|
+
const strokes = isPlainObject(payload) ? payload.strokes : undefined;
|
|
59
|
+
return (
|
|
60
|
+
Array.isArray(strokes) &&
|
|
61
|
+
strokes.length > 0 &&
|
|
62
|
+
strokes.every(
|
|
63
|
+
(stroke) =>
|
|
64
|
+
isPlainObject(stroke) &&
|
|
65
|
+
Array.isArray(stroke.points) &&
|
|
66
|
+
stroke.points.length > 0 &&
|
|
67
|
+
stroke.points.every(
|
|
68
|
+
(point) =>
|
|
69
|
+
isPlainObject(point) &&
|
|
70
|
+
isFiniteNumber(point.x) &&
|
|
71
|
+
isFiniteNumber(point.y) &&
|
|
72
|
+
isFiniteNumber(point.t) &&
|
|
73
|
+
(point.pressure === undefined || isFiniteNumber(point.pressure))
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateContent(db, { type, subtype, comment, payload }) {
|
|
80
|
+
if (type === 'manoeuvre') {
|
|
81
|
+
if (!subtype) {
|
|
82
|
+
throw badRequest('A manoeuvre event requires subtype, the manoeuvre key');
|
|
83
|
+
}
|
|
84
|
+
if (!db.prepare('SELECT 1 FROM manoeuvre_types WHERE key = ?').get(subtype)) {
|
|
85
|
+
throw new ApiError(400, 'unknown_manoeuvre_type', `Unknown manoeuvre type: ${subtype}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (type === 'text_annotation' && !comment) {
|
|
89
|
+
throw badRequest('A text annotation requires a comment');
|
|
90
|
+
}
|
|
91
|
+
if (type === 'handwritten_annotation' && !isValidStrokes(payload)) {
|
|
92
|
+
throw badRequest(
|
|
93
|
+
'A handwritten annotation requires payload.strokes: [{ points: [{ x, y, t, pressure? }] }]'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function serializePayload(payload) {
|
|
99
|
+
return payload === undefined || payload === null ? null : JSON.stringify(payload);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function listEvents(db, entryId, { type, limit, offset }) {
|
|
103
|
+
requireEntryRow(db, entryId);
|
|
104
|
+
return paginate(db, {
|
|
105
|
+
table: 'events',
|
|
106
|
+
where: type === undefined ? 'WHERE entry_id = ?' : 'WHERE entry_id = ? AND type = ?',
|
|
107
|
+
params: type === undefined ? [entryId] : [entryId, type],
|
|
108
|
+
orderBy: 'time, id',
|
|
109
|
+
limit,
|
|
110
|
+
offset,
|
|
111
|
+
map: toEvent
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function allEvents(db, entryId) {
|
|
116
|
+
return db
|
|
117
|
+
.prepare('SELECT * FROM events WHERE entry_id = ? ORDER BY time, id')
|
|
118
|
+
.all(entryId)
|
|
119
|
+
.map(toEvent);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function positionAt(db, time, { now, vesselPosition }) {
|
|
123
|
+
if (time === undefined) {
|
|
124
|
+
return vesselPosition;
|
|
125
|
+
}
|
|
126
|
+
const target = Date.parse(time);
|
|
127
|
+
const nearest = [
|
|
128
|
+
db.prepare(
|
|
129
|
+
'SELECT time, lat, lon FROM track_points WHERE time <= ? ORDER BY time DESC LIMIT 1'
|
|
130
|
+
),
|
|
131
|
+
db.prepare('SELECT time, lat, lon FROM track_points WHERE time >= ? ORDER BY time LIMIT 1')
|
|
132
|
+
]
|
|
133
|
+
.map((statement) => statement.get(time))
|
|
134
|
+
.filter(
|
|
135
|
+
(point) => point && Math.abs(Date.parse(point.time) - target) <= TRACK_POSITION_WINDOW_MS
|
|
136
|
+
)
|
|
137
|
+
.sort(
|
|
138
|
+
(a, b) => Math.abs(Date.parse(a.time) - target) - Math.abs(Date.parse(b.time) - target)
|
|
139
|
+
)[0];
|
|
140
|
+
if (nearest) {
|
|
141
|
+
return { lat: nearest.lat, lon: nearest.lon };
|
|
142
|
+
}
|
|
143
|
+
return Math.abs(Date.parse(now) - target) <= CURRENT_POSITION_WINDOW_MS ? vesselPosition : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findByClientRef(db, clientRef) {
|
|
147
|
+
if (clientRef === undefined) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const row = db.prepare('SELECT * FROM events WHERE client_ref = ?').get(clientRef);
|
|
151
|
+
return row ? toEvent(row) : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function insertEvent(db, entryId, input, position, now) {
|
|
155
|
+
const { lastInsertRowid } = db
|
|
156
|
+
.prepare(
|
|
157
|
+
`INSERT INTO events (
|
|
158
|
+
entry_id, time, type, subtype, lat, lon, comment, payload, source, client_ref, created_at
|
|
159
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'manual', ?, ?)`
|
|
160
|
+
)
|
|
161
|
+
.run(
|
|
162
|
+
entryId,
|
|
163
|
+
input.time ?? now,
|
|
164
|
+
input.type,
|
|
165
|
+
input.subtype ?? null,
|
|
166
|
+
position ? position.lat : null,
|
|
167
|
+
position ? position.lon : null,
|
|
168
|
+
input.comment ?? null,
|
|
169
|
+
serializePayload(input.payload),
|
|
170
|
+
input.clientRef ?? null,
|
|
171
|
+
now
|
|
172
|
+
);
|
|
173
|
+
return toEvent(requireEventRow(db, Number(lastInsertRowid)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Returns { event, created }: a clientRef already logged answers with that
|
|
177
|
+
// event instead of a duplicate.
|
|
178
|
+
function createEvent(db, entryId, input, context) {
|
|
179
|
+
requireEntryRow(db, entryId);
|
|
180
|
+
validateContent(db, input);
|
|
181
|
+
const existing = findByClientRef(db, input.clientRef);
|
|
182
|
+
if (existing) {
|
|
183
|
+
return { event: existing, created: false };
|
|
184
|
+
}
|
|
185
|
+
const position =
|
|
186
|
+
input.position === undefined ? positionAt(db, input.time, context) : input.position;
|
|
187
|
+
return { event: insertEvent(db, entryId, input, position, context.now), created: true };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// An entry from the crew, attached to the passage it belongs to: the open one;
|
|
191
|
+
// failing that, a departure manoeuvre opens one; failing that, the last
|
|
192
|
+
// passage while the vessel is still at its arrival.
|
|
193
|
+
function logCrewEvent(db, input, context) {
|
|
194
|
+
validateContent(db, input);
|
|
195
|
+
return withTransaction(db, () => {
|
|
196
|
+
const existing = findByClientRef(db, input.clientRef);
|
|
197
|
+
if (existing) {
|
|
198
|
+
return { event: existing, created: false, openedEntry: false };
|
|
199
|
+
}
|
|
200
|
+
const position =
|
|
201
|
+
input.position === undefined ? positionAt(db, input.time, context) : input.position;
|
|
202
|
+
const time = input.time ?? context.now;
|
|
203
|
+
|
|
204
|
+
let entryId = activeEntryId(db);
|
|
205
|
+
let openedEntry = false;
|
|
206
|
+
if (entryId === null && input.type === 'manoeuvre' && DEPARTURE_MANOEUVRES.has(input.subtype)) {
|
|
207
|
+
entryId = openEntryByHand(db, {
|
|
208
|
+
time,
|
|
209
|
+
position,
|
|
210
|
+
placeMatchRadius: context.placeMatchRadius,
|
|
211
|
+
now: context.now
|
|
212
|
+
});
|
|
213
|
+
openedEntry = true;
|
|
214
|
+
}
|
|
215
|
+
entryId ??= entryNearArrival(db, position);
|
|
216
|
+
if (entryId === null) {
|
|
217
|
+
throw conflict(
|
|
218
|
+
'no_passage',
|
|
219
|
+
'No passage is open and the vessel is not at the last arrival; cast off or weigh anchor first'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const event = insertEvent(db, entryId, input, position, context.now);
|
|
224
|
+
if (openedEntry) {
|
|
225
|
+
db.prepare('UPDATE log_entries SET opened_by_event_id = ? WHERE id = ?').run(
|
|
226
|
+
event.id,
|
|
227
|
+
entryId
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return { event, created: true, openedEntry };
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function updateEvent(db, id, patch) {
|
|
235
|
+
const current = toEvent(requireEventRow(db, id));
|
|
236
|
+
|
|
237
|
+
if (!CLIENT_EVENT_TYPES.includes(current.type)) {
|
|
238
|
+
const disallowed = Object.keys(patch).filter((field) => field !== 'comment');
|
|
239
|
+
if (disallowed.length > 0) {
|
|
240
|
+
throw badRequest(`Only comment can be edited on a ${current.type} event`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const next = { ...current, ...patch };
|
|
245
|
+
validateContent(db, next);
|
|
246
|
+
|
|
247
|
+
db.prepare('UPDATE events SET time = ?, subtype = ?, comment = ?, payload = ? WHERE id = ?').run(
|
|
248
|
+
next.time,
|
|
249
|
+
next.subtype,
|
|
250
|
+
next.comment,
|
|
251
|
+
serializePayload(next.payload),
|
|
252
|
+
id
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
return toEvent(requireEventRow(db, id));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Undoing the departure that opened a passage, before the vessel moved and
|
|
259
|
+
// before anything else was logged in it, takes the passage away too.
|
|
260
|
+
function deleteEvent(db, id) {
|
|
261
|
+
requireEventRow(db, id);
|
|
262
|
+
withTransaction(db, () => {
|
|
263
|
+
const opened = db
|
|
264
|
+
.prepare(
|
|
265
|
+
`SELECT id FROM log_entries
|
|
266
|
+
WHERE opened_by_event_id = ? AND state = 'active' AND last_moving_at IS NULL
|
|
267
|
+
AND NOT EXISTS (SELECT 1 FROM events WHERE entry_id = log_entries.id AND id <> ?)`
|
|
268
|
+
)
|
|
269
|
+
.get(id, id);
|
|
270
|
+
if (opened) {
|
|
271
|
+
db.prepare('DELETE FROM log_entries WHERE id = ?').run(opened.id);
|
|
272
|
+
} else {
|
|
273
|
+
db.prepare('DELETE FROM events WHERE id = ?').run(id);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
EVENT_TYPES,
|
|
280
|
+
CLIENT_EVENT_TYPES,
|
|
281
|
+
listEvents,
|
|
282
|
+
allEvents,
|
|
283
|
+
createEvent,
|
|
284
|
+
logCrewEvent,
|
|
285
|
+
updateEvent,
|
|
286
|
+
deleteEvent
|
|
287
|
+
};
|
package/lib/export.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { getSchemaVersion } = require('./database');
|
|
5
|
+
const { conflict } = require('./errors');
|
|
6
|
+
const { toEntry } = require('./entries');
|
|
7
|
+
const { allEvents } = require('./events');
|
|
8
|
+
const { toCsv, toGpx } = require('./formats');
|
|
9
|
+
const { allSegments } = require('./propulsion');
|
|
10
|
+
const { allTrackPoints, allObservations } = require('./track');
|
|
11
|
+
|
|
12
|
+
const EXPORT_FORMATS = {
|
|
13
|
+
json: {
|
|
14
|
+
contentType: 'application/json',
|
|
15
|
+
render: (bundles, meta) => `${JSON.stringify({ ...meta, entries: bundles }, null, 2)}\n`
|
|
16
|
+
},
|
|
17
|
+
csv: { contentType: 'text/csv; charset=utf-8', render: toCsv },
|
|
18
|
+
gpx: { contentType: 'application/gpx+xml', render: toGpx }
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function bundleOf(db, row) {
|
|
22
|
+
return {
|
|
23
|
+
entry: toEntry(row),
|
|
24
|
+
trackPoints: allTrackPoints(db, row.id),
|
|
25
|
+
observations: allObservations(db, row.id),
|
|
26
|
+
propulsion: allSegments(db, row.id),
|
|
27
|
+
events: allEvents(db, row.id)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function collectBundles(db, { from, to }) {
|
|
32
|
+
const conditions = [];
|
|
33
|
+
const params = [];
|
|
34
|
+
if (from !== undefined) {
|
|
35
|
+
conditions.push('start_time >= ?');
|
|
36
|
+
params.push(from);
|
|
37
|
+
}
|
|
38
|
+
if (to !== undefined) {
|
|
39
|
+
conditions.push('start_time < ?');
|
|
40
|
+
params.push(to);
|
|
41
|
+
}
|
|
42
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
43
|
+
|
|
44
|
+
return db
|
|
45
|
+
.prepare(`SELECT * FROM log_entries ${where} ORDER BY start_time, id`)
|
|
46
|
+
.all(...params)
|
|
47
|
+
.map((row) => bundleOf(db, row));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function exportMeta(db, now) {
|
|
51
|
+
return { exportedAt: now, schemaVersion: getSchemaVersion(db), units: 'Signal K SI' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderExport(db, format, range, now) {
|
|
55
|
+
const { contentType, render } = EXPORT_FORMATS[format];
|
|
56
|
+
return {
|
|
57
|
+
contentType,
|
|
58
|
+
filename: `chiplog.${format}`,
|
|
59
|
+
body: render(collectBundles(db, range), exportMeta(db, now))
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The drive may be pulled the moment a write returns when abandoning ship, so
|
|
64
|
+
// flush to the device before the rename makes the new file visible.
|
|
65
|
+
async function writeFileDurably(target, contents) {
|
|
66
|
+
const temporary = `${target}.tmp`;
|
|
67
|
+
const handle = await fs.open(temporary, 'w');
|
|
68
|
+
try {
|
|
69
|
+
await handle.writeFile(contents);
|
|
70
|
+
await handle.sync();
|
|
71
|
+
} finally {
|
|
72
|
+
await handle.close();
|
|
73
|
+
}
|
|
74
|
+
await fs.rename(temporary, target);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The USB copy keeps one set of files per passage in this subdirectory, named
|
|
78
|
+
// so that sorting by name sorts by departure time.
|
|
79
|
+
const USB_SUBDIRECTORY = 'chiplog';
|
|
80
|
+
const MANIFEST = '.chiplog-export.json';
|
|
81
|
+
const PASSAGE_FILE = /^\d{4}-\d{2}-\d{2}_\d{4}Z_.+\.(json|csv|gpx)(\.tmp)?$/;
|
|
82
|
+
const NAME_PART_LENGTH = 40;
|
|
83
|
+
|
|
84
|
+
// FAT and exFAT drives refuse some characters, and accented names come back
|
|
85
|
+
// differently normalised from one system to another: plain ASCII is safest.
|
|
86
|
+
function namePart(text) {
|
|
87
|
+
const slug = (text ?? '')
|
|
88
|
+
.normalize('NFD')
|
|
89
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
90
|
+
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
91
|
+
.replace(/^-+|-+$/g, '')
|
|
92
|
+
.slice(0, NAME_PART_LENGTH)
|
|
93
|
+
.replace(/-+$/, '');
|
|
94
|
+
return slug || 'unnamed';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 2026-09-13_0612Z_La-Rochelle_Les-Sables-d-Olonne, in UTC like every time in
|
|
98
|
+
// the logbook; a passage in progress ends in "underway" until it arrives.
|
|
99
|
+
function passageBaseName(row) {
|
|
100
|
+
const start = row.start_time;
|
|
101
|
+
const stamp = `${start.slice(0, 10)}_${start.slice(11, 13)}${start.slice(14, 16)}Z`;
|
|
102
|
+
const arrival = row.state === 'active' ? 'underway' : namePart(row.end_place_name);
|
|
103
|
+
return `${stamp}_${namePart(row.start_place_name)}_${arrival}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Changes whenever anything exported for the passage does. Track points and
|
|
107
|
+
// observations are only ever added or moved by a merge, so their count and
|
|
108
|
+
// last id are enough; events and segments can be edited in place.
|
|
109
|
+
function passageFingerprint(db, row) {
|
|
110
|
+
const summary = (table) =>
|
|
111
|
+
db
|
|
112
|
+
.prepare(`SELECT COUNT(*) AS n, IFNULL(MAX(id), 0) AS last FROM ${table} WHERE entry_id = ?`)
|
|
113
|
+
.get(row.id);
|
|
114
|
+
const hash = crypto.createHash('sha256');
|
|
115
|
+
hash.update(JSON.stringify(row));
|
|
116
|
+
hash.update(JSON.stringify([summary('track_points'), summary('observations')]));
|
|
117
|
+
for (const table of ['propulsion_segments', 'events']) {
|
|
118
|
+
for (const item of db
|
|
119
|
+
.prepare(`SELECT * FROM ${table} WHERE entry_id = ? ORDER BY id`)
|
|
120
|
+
.iterate(row.id)) {
|
|
121
|
+
hash.update(JSON.stringify(item));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return hash.digest('hex');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readManifest(directory) {
|
|
128
|
+
try {
|
|
129
|
+
const manifest = JSON.parse(await fs.readFile(path.join(directory, MANIFEST), 'utf8'));
|
|
130
|
+
return manifest && typeof manifest.passages === 'object' ? manifest.passages : {};
|
|
131
|
+
} catch {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function exists(file) {
|
|
137
|
+
return fs.stat(file).then(
|
|
138
|
+
() => true,
|
|
139
|
+
() => false
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Writes the files of passages that are new or changed since the last export,
|
|
144
|
+
// leaves the others alone — a USB drive is slow and wears — and removes the
|
|
145
|
+
// files of passages that were deleted, merged or renamed.
|
|
146
|
+
async function writeUsbExport(db, directory, now) {
|
|
147
|
+
const stats = await fs.stat(directory).catch(() => null);
|
|
148
|
+
if (!stats || !stats.isDirectory()) {
|
|
149
|
+
throw conflict(
|
|
150
|
+
'usb_export_unavailable',
|
|
151
|
+
`Export directory ${directory} is not available; is the USB drive mounted?`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const target = path.join(directory, USB_SUBDIRECTORY);
|
|
155
|
+
await fs.mkdir(target, { recursive: true });
|
|
156
|
+
|
|
157
|
+
const previous = await readManifest(target);
|
|
158
|
+
const passages = {};
|
|
159
|
+
const expected = new Set();
|
|
160
|
+
const written = [];
|
|
161
|
+
let unchanged = 0;
|
|
162
|
+
const meta = exportMeta(db, now);
|
|
163
|
+
const taken = new Set();
|
|
164
|
+
|
|
165
|
+
for (const row of db.prepare('SELECT * FROM log_entries ORDER BY start_time, id').all()) {
|
|
166
|
+
let base = passageBaseName(row);
|
|
167
|
+
for (let n = 2; taken.has(base); n += 1) {
|
|
168
|
+
base = `${passageBaseName(row)}_${n}`;
|
|
169
|
+
}
|
|
170
|
+
taken.add(base);
|
|
171
|
+
const files = Object.keys(EXPORT_FORMATS).map((format) => `${base}.${format}`);
|
|
172
|
+
files.forEach((file) => expected.add(file));
|
|
173
|
+
|
|
174
|
+
const fingerprint = passageFingerprint(db, row);
|
|
175
|
+
const known = previous[row.id];
|
|
176
|
+
const present = (await Promise.all(files.map((file) => exists(path.join(target, file))))).every(
|
|
177
|
+
Boolean
|
|
178
|
+
);
|
|
179
|
+
// Files already on the drive with no record — a lost manifest — are taken
|
|
180
|
+
// as they are rather than rewritten.
|
|
181
|
+
const upToDate =
|
|
182
|
+
present &&
|
|
183
|
+
(known === undefined || (known.base === base && known.fingerprint === fingerprint));
|
|
184
|
+
passages[row.id] = { base, fingerprint };
|
|
185
|
+
if (upToDate) {
|
|
186
|
+
unchanged += 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const bundles = [bundleOf(db, row)];
|
|
191
|
+
for (const [format, { render }] of Object.entries(EXPORT_FORMATS)) {
|
|
192
|
+
const file = path.join(target, `${base}.${format}`);
|
|
193
|
+
await writeFileDurably(file, render(bundles, meta));
|
|
194
|
+
written.push(file);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const removed = [];
|
|
199
|
+
for (const name of await fs.readdir(target)) {
|
|
200
|
+
if (PASSAGE_FILE.test(name) && !expected.has(name)) {
|
|
201
|
+
await fs.rm(path.join(target, name), { force: true });
|
|
202
|
+
removed.push(path.join(target, name));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
await writeFileDurably(
|
|
206
|
+
path.join(target, MANIFEST),
|
|
207
|
+
`${JSON.stringify({ exportedAt: now, passages }, null, 2)}\n`
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
directory: target,
|
|
212
|
+
entries: Object.keys(passages).length,
|
|
213
|
+
written: written.length / Object.keys(EXPORT_FORMATS).length,
|
|
214
|
+
unchanged,
|
|
215
|
+
files: written,
|
|
216
|
+
removed
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { EXPORT_FORMATS, renderExport, writeUsbExport };
|