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
@@ -0,0 +1,131 @@
1
+ const { writeUsbExport } = require('./export');
2
+
3
+ // SPEC §4.5: the abandon-ship copy is kept current without anyone thinking of
4
+ // it — every few minutes, and as soon as a passage ends.
5
+ const USB_EXPORT_DEFAULTS = { usbExportIntervalMinutes: 15, usbExportOnArrival: true };
6
+
7
+ const CHECK_INTERVAL_MS = 30 * 1000;
8
+ // Leaves the drive time to be mounted after the host boots.
9
+ const FIRST_EXPORT_DELAY_MS = 60 * 1000;
10
+
11
+ const iso = (ms) => new Date(ms).toISOString();
12
+
13
+ function createUsbExportScheduler({
14
+ db,
15
+ settings,
16
+ clock = Date.now,
17
+ write = writeUsbExport,
18
+ log = () => {}
19
+ }) {
20
+ const intervalMs = settings.usbExportIntervalMinutes * 60 * 1000;
21
+ let nextDueAt = intervalMs > 0 ? clock() + FIRST_EXPORT_DELAY_MS : null;
22
+ let lastActiveEntryId;
23
+ let current = null;
24
+ let queued = null;
25
+ let stopped = false;
26
+ let lastSuccess = null;
27
+ let lastError = null;
28
+
29
+ async function perform(reason) {
30
+ const startedAt = clock();
31
+ try {
32
+ const result = await write(db, settings.usbExportPath, iso(startedAt));
33
+ if (lastError) {
34
+ log('info', `USB copy written again (${reason})`);
35
+ }
36
+ lastSuccess = {
37
+ at: iso(clock()),
38
+ reason,
39
+ entries: result.entries,
40
+ written: result.written,
41
+ unchanged: result.unchanged,
42
+ removed: result.removed.length
43
+ };
44
+ lastError = null;
45
+ return result;
46
+ } catch (err) {
47
+ if (stopped) {
48
+ throw err;
49
+ }
50
+ // A missing drive is reported once, not every quarter of an hour.
51
+ if (!lastError || lastError.message !== err.message) {
52
+ log('error', `USB copy failed (${reason}): ${err.message}`);
53
+ }
54
+ lastError = { at: iso(clock()), reason, code: err.code ?? null, message: err.message };
55
+ throw err;
56
+ }
57
+ }
58
+
59
+ // One write at a time: a request while one runs is served by a single write
60
+ // after it, which then includes whatever changed meanwhile.
61
+ function run(reason) {
62
+ if (!settings.usbExportPath) {
63
+ return Promise.resolve(null);
64
+ }
65
+ if (!current) {
66
+ current = perform(reason).finally(() => {
67
+ current = null;
68
+ });
69
+ return current;
70
+ }
71
+ queued ??= current
72
+ .catch(() => {})
73
+ .then(() => {
74
+ queued = null;
75
+ return run(reason);
76
+ });
77
+ return queued;
78
+ }
79
+
80
+ const background = (reason) => run(reason).catch(() => {});
81
+
82
+ return {
83
+ run,
84
+
85
+ // Called every few seconds.
86
+ tick() {
87
+ if (stopped || nextDueAt === null || !settings.usbExportPath) {
88
+ return;
89
+ }
90
+ const now = clock();
91
+ if (now >= nextDueAt) {
92
+ nextDueAt = now + intervalMs;
93
+ background('scheduled');
94
+ }
95
+ },
96
+
97
+ // Called with each detection outcome: the open passage going away, or
98
+ // giving way to another, is an arrival.
99
+ afterDetection({ activeEntryId }) {
100
+ const previous = lastActiveEntryId;
101
+ lastActiveEntryId = activeEntryId;
102
+ if (
103
+ !stopped &&
104
+ settings.usbExportOnArrival &&
105
+ previous !== undefined &&
106
+ previous !== null &&
107
+ previous !== activeEntryId
108
+ ) {
109
+ background('arrival');
110
+ }
111
+ },
112
+
113
+ status() {
114
+ return {
115
+ configured: Boolean(settings.usbExportPath),
116
+ intervalMinutes: settings.usbExportIntervalMinutes,
117
+ onArrival: settings.usbExportOnArrival,
118
+ running: current !== null,
119
+ nextAt: settings.usbExportPath && nextDueAt !== null ? iso(nextDueAt) : null,
120
+ lastSuccess,
121
+ lastError
122
+ };
123
+ },
124
+
125
+ stop() {
126
+ stopped = true;
127
+ }
128
+ };
129
+ }
130
+
131
+ module.exports = { createUsbExportScheduler, USB_EXPORT_DEFAULTS, CHECK_INTERVAL_MS };
@@ -0,0 +1,105 @@
1
+ const { badRequest } = require('./errors');
2
+
3
+ const DEFAULT_LIMIT = 50;
4
+ const MAX_LIMIT = 500;
5
+
6
+ function isPlainObject(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+
10
+ function requireBody(body, allowedFields) {
11
+ if (!isPlainObject(body)) {
12
+ throw badRequest('Request body must be a JSON object');
13
+ }
14
+ const unknown = Object.keys(body).filter((field) => !allowedFields.includes(field));
15
+ if (unknown.length > 0) {
16
+ throw badRequest(`Unknown field(s): ${unknown.join(', ')}`);
17
+ }
18
+ return body;
19
+ }
20
+
21
+ function parseId(value, name = 'id') {
22
+ if (!/^[1-9]\d*$/.test(String(value))) {
23
+ throw badRequest(`${name} must be a positive integer`);
24
+ }
25
+ return Number(value);
26
+ }
27
+
28
+ function parseTimestamp(value, name) {
29
+ const date = typeof value === 'string' ? new Date(value) : null;
30
+ if (!date || Number.isNaN(date.getTime())) {
31
+ throw badRequest(`${name} must be an ISO 8601 timestamp`);
32
+ }
33
+ return date.toISOString();
34
+ }
35
+
36
+ function parseOptionalTimestamp(value, name) {
37
+ return value === undefined ? undefined : parseTimestamp(value, name);
38
+ }
39
+
40
+ function parseNonNegativeNumber(value, name) {
41
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
42
+ throw badRequest(`${name} must be a non-negative number`);
43
+ }
44
+ return value;
45
+ }
46
+
47
+ function parsePosition(value, name) {
48
+ if (value === null) {
49
+ return null;
50
+ }
51
+ const valid =
52
+ isPlainObject(value) &&
53
+ typeof value.lat === 'number' &&
54
+ typeof value.lon === 'number' &&
55
+ value.lat >= -90 &&
56
+ value.lat <= 90 &&
57
+ value.lon >= -180 &&
58
+ value.lon <= 180;
59
+ if (!valid) {
60
+ throw badRequest(`${name} must be { lat, lon } in decimal degrees, or null`);
61
+ }
62
+ return { lat: value.lat, lon: value.lon };
63
+ }
64
+
65
+ function parseString(value, name, { maxLength = 1000, allowNull = false } = {}) {
66
+ if (value === null && allowNull) {
67
+ return null;
68
+ }
69
+ if (typeof value !== 'string' || value.trim() === '' || value.length > maxLength) {
70
+ throw badRequest(`${name} must be a non-empty string of at most ${maxLength} characters`);
71
+ }
72
+ return value.trim();
73
+ }
74
+
75
+ function parsePagination(query) {
76
+ const limit = query.limit === undefined ? DEFAULT_LIMIT : Number(query.limit);
77
+ const offset = query.offset === undefined ? 0 : Number(query.offset);
78
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
79
+ throw badRequest(`limit must be an integer between 1 and ${MAX_LIMIT}`);
80
+ }
81
+ if (!Number.isInteger(offset) || offset < 0) {
82
+ throw badRequest('offset must be a non-negative integer');
83
+ }
84
+ return { limit, offset };
85
+ }
86
+
87
+ function parseEnum(value, name, allowed) {
88
+ if (!allowed.includes(value)) {
89
+ throw badRequest(`${name} must be one of: ${allowed.join(', ')}`);
90
+ }
91
+ return value;
92
+ }
93
+
94
+ module.exports = {
95
+ isPlainObject,
96
+ requireBody,
97
+ parseId,
98
+ parseTimestamp,
99
+ parseOptionalTimestamp,
100
+ parseNonNegativeNumber,
101
+ parsePosition,
102
+ parseString,
103
+ parsePagination,
104
+ parseEnum
105
+ };
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "signalk-chiplog",
3
+ "version": "1.0.0",
4
+ "description": "Automated digital logbook plugin for Signal K, with keyboard/handwritten entry on tablet",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "lib/",
9
+ "public/",
10
+ "CHANGELOG.md"
11
+ ],
12
+ "scripts": {
13
+ "test": "node --test",
14
+ "lint": "eslint .",
15
+ "lint:fix": "eslint . --fix",
16
+ "format": "prettier --write .",
17
+ "format:check": "prettier --check .",
18
+ "demo:seed": "node scripts/seed-demo.js",
19
+ "prepare": "husky && node scripts/vendor.js"
20
+ },
21
+ "keywords": [
22
+ "signalk-node-server-plugin",
23
+ "signalk-webapp",
24
+ "signalk-category-utility",
25
+ "signalk",
26
+ "logbook"
27
+ ],
28
+ "signalk": {
29
+ "displayName": "Chiplog",
30
+ "appIcon": "./icon.svg"
31
+ },
32
+ "author": "Cedric RICARD <ricard33@gmail.com>",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/ricard33/signalk-chiplog.git"
37
+ },
38
+ "homepage": "https://github.com/ricard33/signalk-chiplog#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/ricard33/signalk-chiplog/issues"
41
+ },
42
+ "engines": {
43
+ "node": ">=22.13.0"
44
+ },
45
+ "lint-staged": {
46
+ "*.{js,mjs}": [
47
+ "eslint --fix",
48
+ "prettier --write"
49
+ ],
50
+ "*.{json,md,css,html}": [
51
+ "prettier --write"
52
+ ]
53
+ },
54
+ "devDependencies": {
55
+ "@eslint/js": "^10.0.1",
56
+ "eslint": "^10.10.0",
57
+ "eslint-config-prettier": "^10.1.8",
58
+ "express": "^4.22.2",
59
+ "globals": "^17.12.0",
60
+ "htm": "^3.1.1",
61
+ "husky": "^9.1.7",
62
+ "leaflet": "^1.9.4",
63
+ "lint-staged": "^17.5.1",
64
+ "preact": "^10.29.8",
65
+ "prettier": "^3.9.6"
66
+ }
67
+ }