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/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 };