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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// What the tablet does with an entry: send it at once when the server answers,
|
|
2
|
+
// otherwise keep it in the outbox with the time it was made. Undo and comments
|
|
3
|
+
// follow the entry wherever it is — still queued, or already logged.
|
|
4
|
+
|
|
5
|
+
import { createOutbox, isTransient } from './outbox.mjs';
|
|
6
|
+
|
|
7
|
+
export const SEND_TIMEOUT_MS = 8000;
|
|
8
|
+
|
|
9
|
+
export function createJournal({ request, storage, clock, newRef, onChange = () => {} }) {
|
|
10
|
+
// Logged events by clientRef, for undo and comments after delivery.
|
|
11
|
+
const delivered = new Map();
|
|
12
|
+
// Entries undone while their replay may already be on its way.
|
|
13
|
+
const cancelled = new Set();
|
|
14
|
+
|
|
15
|
+
const send = async (item) => {
|
|
16
|
+
try {
|
|
17
|
+
return await request(item.method, item.path, item.body, { timeoutMs: SEND_TIMEOUT_MS });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
// Deleting something already gone is what was wanted.
|
|
20
|
+
if (item.method === 'DELETE' && error.status === 404) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const outbox = createOutbox({ storage, send, onChange });
|
|
28
|
+
|
|
29
|
+
function queue(method, path, body) {
|
|
30
|
+
outbox.add({ ref: newRef(), method, path, body, createdAt: clock.now() });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Tries now; on a transient failure queues it and reports the error.
|
|
34
|
+
async function sendOrQueue(method, path, body) {
|
|
35
|
+
if (outbox.size === 0) {
|
|
36
|
+
try {
|
|
37
|
+
await send({ method, path, body });
|
|
38
|
+
return null;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (!isTransient(error)) {
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
queue(method, path, body);
|
|
44
|
+
return error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
queue(method, path, body);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function flush() {
|
|
52
|
+
const outcome = await outbox.flush();
|
|
53
|
+
for (const { item, result } of outcome.sent) {
|
|
54
|
+
if (item.method !== 'POST' || !result) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
delivered.set(item.body.clientRef, result);
|
|
58
|
+
if (cancelled.delete(item.body.clientRef)) {
|
|
59
|
+
queue('DELETE', `/events/${result.id}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (outcome.sent.length > 0 && outbox.size > 0 && !outcome.blocked) {
|
|
63
|
+
return flush();
|
|
64
|
+
}
|
|
65
|
+
return outcome;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
outbox,
|
|
70
|
+
flush,
|
|
71
|
+
|
|
72
|
+
// Resolves to { status: 'sent', event } | { status: 'queued', error } and
|
|
73
|
+
// rejects with the server's refusal (a 400 or 409).
|
|
74
|
+
async log(body) {
|
|
75
|
+
const ref = newRef();
|
|
76
|
+
const madeAt = new Date(clock.now()).toISOString();
|
|
77
|
+
const entry = { ...body, clientRef: ref };
|
|
78
|
+
const enqueue = () =>
|
|
79
|
+
outbox.add({
|
|
80
|
+
ref,
|
|
81
|
+
method: 'POST',
|
|
82
|
+
path: '/events',
|
|
83
|
+
body: { ...entry, time: madeAt },
|
|
84
|
+
createdAt: Date.parse(madeAt)
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Behind queued entries it waits its turn, so the log keeps its order.
|
|
88
|
+
if (outbox.size > 0) {
|
|
89
|
+
enqueue();
|
|
90
|
+
return { status: 'queued', ref, error: null };
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const event = await send({ method: 'POST', path: '/events', body: entry });
|
|
94
|
+
delivered.set(ref, event);
|
|
95
|
+
return { status: 'sent', ref, event };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (!isTransient(error)) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
enqueue();
|
|
101
|
+
return { status: 'queued', ref, error };
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
eventFor(ref) {
|
|
106
|
+
return delivered.get(ref) ?? null;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
isQueued(ref) {
|
|
110
|
+
return outbox.snapshot().pending.some((item) => item.ref === ref);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async undo(ref) {
|
|
114
|
+
if (outbox.remove(ref)) {
|
|
115
|
+
// Removed from the queue, but a replay may have picked it up already.
|
|
116
|
+
cancelled.add(ref);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const event = delivered.get(ref);
|
|
120
|
+
if (!event) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
delivered.delete(ref);
|
|
124
|
+
return sendOrQueue('DELETE', `/events/${event.id}`);
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async comment(ref, comment) {
|
|
128
|
+
if (outbox.update(ref, { comment })) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const event = delivered.get(ref);
|
|
132
|
+
if (!event) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
delivered.set(ref, { ...event, comment });
|
|
136
|
+
return sendOrQueue('PATCH', `/events/${event.id}`, { comment });
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { html, render, useEffect, useMemo, useState } from '../../vendor/preact-htm.mjs';
|
|
2
|
+
import { fetchAll, get, onResponse, request } from '../../js/api.mjs';
|
|
3
|
+
import { createLocale, LocaleProvider, useLocale, usePolling } from '../../js/context.mjs';
|
|
4
|
+
import { randomId } from '../../js/ids.mjs';
|
|
5
|
+
import { pickLanguage } from '../../js/i18n.mjs';
|
|
6
|
+
import { createServerClock } from './clock.mjs';
|
|
7
|
+
import { createJournal } from './journal.mjs';
|
|
8
|
+
import { AccessGate } from './components/AccessGate.mjs';
|
|
9
|
+
import { CommentDialog, Toast } from './components/Dialogs.mjs';
|
|
10
|
+
import { ManoeuvrePad } from './components/ManoeuvrePad.mjs';
|
|
11
|
+
import { NotePanel } from './components/NotePanel.mjs';
|
|
12
|
+
import { RecentList } from './components/RecentList.mjs';
|
|
13
|
+
import { SketchPanel } from './components/SketchPanel.mjs';
|
|
14
|
+
import { StatusHeader } from './components/StatusHeader.mjs';
|
|
15
|
+
|
|
16
|
+
const STATE_REFRESH_MS = 15 * 1000;
|
|
17
|
+
const FLUSH_INTERVAL_MS = 10 * 1000;
|
|
18
|
+
const TOAST_MS = 10 * 1000;
|
|
19
|
+
const NIGHT_KEY = 'chiplog.night';
|
|
20
|
+
const TYPES_KEY = 'chiplog.manoeuvreTypes';
|
|
21
|
+
|
|
22
|
+
// The shortcuts shipped with the plugin, until the server's list has been seen
|
|
23
|
+
// once: an app started with no connection still has its buttons.
|
|
24
|
+
const BUILTIN_TYPES = [
|
|
25
|
+
'tack',
|
|
26
|
+
'gybe',
|
|
27
|
+
'reef_in',
|
|
28
|
+
'reef_out',
|
|
29
|
+
'sail_change',
|
|
30
|
+
'anchor_down',
|
|
31
|
+
'anchor_up',
|
|
32
|
+
'moor',
|
|
33
|
+
'cast_off',
|
|
34
|
+
'watch_change'
|
|
35
|
+
].map((key, index) => ({ key, label: key, sortOrder: (index + 1) * 10, enabled: true }));
|
|
36
|
+
|
|
37
|
+
function knownTypes() {
|
|
38
|
+
try {
|
|
39
|
+
const stored = JSON.parse(readSetting(TYPES_KEY) ?? 'null');
|
|
40
|
+
return Array.isArray(stored) && stored.length > 0 ? stored : BUILTIN_TYPES;
|
|
41
|
+
} catch {
|
|
42
|
+
return BUILTIN_TYPES;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function browserStorage() {
|
|
47
|
+
try {
|
|
48
|
+
return window.localStorage;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readSetting(key) {
|
|
55
|
+
try {
|
|
56
|
+
return browserStorage()?.getItem(key) ?? null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeSetting(key, value) {
|
|
63
|
+
try {
|
|
64
|
+
browserStorage()?.setItem(key, value);
|
|
65
|
+
} catch {
|
|
66
|
+
// A preference for this page only.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const clock = createServerClock();
|
|
71
|
+
let markOnline = () => {};
|
|
72
|
+
onResponse((response, sentAt, receivedAt) => {
|
|
73
|
+
clock.observe(response.headers.get('date'), sentAt, receivedAt);
|
|
74
|
+
markOnline(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function App({ journal }) {
|
|
78
|
+
const { t } = useLocale();
|
|
79
|
+
const [state, setState] = useState(null);
|
|
80
|
+
const [entry, setEntry] = useState(null);
|
|
81
|
+
const [lastEntryId, setLastEntryId] = useState(null);
|
|
82
|
+
const [types, setTypes] = useState(knownTypes);
|
|
83
|
+
const [online, setOnline] = useState(true);
|
|
84
|
+
const [forbidden, setForbidden] = useState(false);
|
|
85
|
+
const [queue, setQueue] = useState(journal.outbox.snapshot());
|
|
86
|
+
const [toast, setToast] = useState(null);
|
|
87
|
+
const [commenting, setCommenting] = useState(null);
|
|
88
|
+
const [tab, setTab] = useState('note');
|
|
89
|
+
const [night, setNight] = useState(readSetting(NIGHT_KEY) === 'on');
|
|
90
|
+
const [version, setVersion] = useState(0);
|
|
91
|
+
const [busy, setBusy] = useState(false);
|
|
92
|
+
|
|
93
|
+
markOnline = setOnline;
|
|
94
|
+
const refresh = () => setVersion((v) => v + 1);
|
|
95
|
+
|
|
96
|
+
const noteFailure = (error) => {
|
|
97
|
+
if (error?.code === 'network') {
|
|
98
|
+
setOnline(false);
|
|
99
|
+
}
|
|
100
|
+
if (error?.code === 'forbidden') {
|
|
101
|
+
setForbidden(true);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
journal.setListener(setQueue);
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
document.documentElement.dataset.theme = night ? 'night' : '';
|
|
111
|
+
writeSetting(NIGHT_KEY, night ? 'on' : 'off');
|
|
112
|
+
}, [night]);
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!toast || toast.kind === 'error') {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
const timer = setTimeout(() => setToast(null), TOAST_MS);
|
|
119
|
+
return () => clearTimeout(timer);
|
|
120
|
+
}, [toast]);
|
|
121
|
+
|
|
122
|
+
usePolling(
|
|
123
|
+
(isCurrent) => {
|
|
124
|
+
get('/state')
|
|
125
|
+
.then(async (next) => {
|
|
126
|
+
const active =
|
|
127
|
+
next.activeEntryId === null ? null : await get(`/entries/${next.activeEntryId}`);
|
|
128
|
+
const last = active ? active.id : ((await get('/entries?limit=1')).items[0]?.id ?? null);
|
|
129
|
+
if (isCurrent()) {
|
|
130
|
+
setState(next);
|
|
131
|
+
setEntry(active);
|
|
132
|
+
setLastEntryId(last);
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.catch(noteFailure);
|
|
136
|
+
},
|
|
137
|
+
STATE_REFRESH_MS,
|
|
138
|
+
[version]
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
usePolling(
|
|
142
|
+
(isCurrent) => {
|
|
143
|
+
fetchAll('/manoeuvre-types')
|
|
144
|
+
.then((items) => {
|
|
145
|
+
writeSetting(TYPES_KEY, JSON.stringify(items));
|
|
146
|
+
if (isCurrent()) {
|
|
147
|
+
setTypes(items);
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
.catch(noteFailure);
|
|
151
|
+
},
|
|
152
|
+
0,
|
|
153
|
+
[forbidden, online]
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Replays the queue while it holds anything, and as soon as the network is back.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
const flush = async () => {
|
|
159
|
+
if (journal.outbox.size === 0) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const outcome = await journal.flush();
|
|
163
|
+
noteFailure(outcome.blocked);
|
|
164
|
+
if (outcome.sent.length > 0) {
|
|
165
|
+
refresh();
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const timer = setInterval(flush, FLUSH_INTERVAL_MS);
|
|
169
|
+
window.addEventListener('online', flush);
|
|
170
|
+
flush();
|
|
171
|
+
return () => {
|
|
172
|
+
clearInterval(timer);
|
|
173
|
+
window.removeEventListener('online', flush);
|
|
174
|
+
};
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
const manoeuvreLabels = useMemo(
|
|
178
|
+
() => Object.fromEntries(types.map((type) => [type.key, type.label])),
|
|
179
|
+
[types]
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Resolves to true once the entry is logged or safely queued.
|
|
183
|
+
const log = async (body, what) => {
|
|
184
|
+
setBusy(true);
|
|
185
|
+
try {
|
|
186
|
+
const outcome = await journal.log(body);
|
|
187
|
+
if (outcome.status === 'queued') {
|
|
188
|
+
noteFailure(outcome.error);
|
|
189
|
+
setToast({ kind: 'logged', ref: outcome.ref, what, queued: true });
|
|
190
|
+
} else {
|
|
191
|
+
setToast({
|
|
192
|
+
kind: 'logged',
|
|
193
|
+
ref: outcome.ref,
|
|
194
|
+
what,
|
|
195
|
+
time: outcome.event.time,
|
|
196
|
+
openedEntry: outcome.event.openedEntry
|
|
197
|
+
});
|
|
198
|
+
refresh();
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
setToast({
|
|
203
|
+
kind: 'error',
|
|
204
|
+
message:
|
|
205
|
+
error.code === 'no_passage'
|
|
206
|
+
? t('entry.noPassageError')
|
|
207
|
+
: t('entry.refused', { message: error.message })
|
|
208
|
+
});
|
|
209
|
+
return false;
|
|
210
|
+
} finally {
|
|
211
|
+
setBusy(false);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const undo = async () => {
|
|
216
|
+
const { ref } = toast;
|
|
217
|
+
setToast({ kind: 'undone' });
|
|
218
|
+
noteFailure(await journal.undo(ref));
|
|
219
|
+
refresh();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const saveComment = async (comment) => {
|
|
223
|
+
const target = commenting;
|
|
224
|
+
setCommenting(null);
|
|
225
|
+
// A note is its text: emptying it would be a deletion, which has its own button.
|
|
226
|
+
if (comment === null && target.event?.type === 'text_annotation') {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
if (target.ref) {
|
|
231
|
+
noteFailure(await journal.comment(target.ref, comment));
|
|
232
|
+
} else {
|
|
233
|
+
await request('PATCH', `/events/${target.event.id}`, { comment });
|
|
234
|
+
}
|
|
235
|
+
refresh();
|
|
236
|
+
} catch (error) {
|
|
237
|
+
noteFailure(error);
|
|
238
|
+
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const deleteEvent = async (event) => {
|
|
243
|
+
if (!confirm(t('entry.deleteConfirm'))) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
await request('DELETE', `/events/${event.id}`);
|
|
248
|
+
refresh();
|
|
249
|
+
} catch (error) {
|
|
250
|
+
noteFailure(error);
|
|
251
|
+
setToast({ kind: 'error', message: t('entry.refused', { message: error.message }) });
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const discard = (ref) => {
|
|
256
|
+
journal.outbox.remove(ref);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const commentTitle = commenting?.what ?? t('entry.comment');
|
|
260
|
+
|
|
261
|
+
return html`
|
|
262
|
+
<${StatusHeader}
|
|
263
|
+
state=${state}
|
|
264
|
+
entry=${entry}
|
|
265
|
+
online=${online}
|
|
266
|
+
queued=${queue.pending.length}
|
|
267
|
+
night=${night}
|
|
268
|
+
onToggleNight=${() => setNight((value) => !value)}
|
|
269
|
+
/>
|
|
270
|
+
<main class="entry-main">
|
|
271
|
+
${
|
|
272
|
+
forbidden &&
|
|
273
|
+
html`<${AccessGate}
|
|
274
|
+
onGranted=${() => {
|
|
275
|
+
setForbidden(false);
|
|
276
|
+
journal.flush();
|
|
277
|
+
refresh();
|
|
278
|
+
}}
|
|
279
|
+
/>`
|
|
280
|
+
}
|
|
281
|
+
<div class="entry-columns">
|
|
282
|
+
<${ManoeuvrePad}
|
|
283
|
+
types=${types}
|
|
284
|
+
noPassage=${state !== null && state.activeEntryId === null}
|
|
285
|
+
busy=${busy}
|
|
286
|
+
onLog=${log}
|
|
287
|
+
/>
|
|
288
|
+
<section class="panel writer">
|
|
289
|
+
<div class="tabs" role="tablist">
|
|
290
|
+
${['note', 'sketch'].map(
|
|
291
|
+
(name) => html`<button
|
|
292
|
+
type="button"
|
|
293
|
+
role="tab"
|
|
294
|
+
key=${name}
|
|
295
|
+
class="tab"
|
|
296
|
+
aria-selected=${tab === name ? 'true' : 'false'}
|
|
297
|
+
onClick=${() => setTab(name)}
|
|
298
|
+
>
|
|
299
|
+
${t(`entry.${name}`)}
|
|
300
|
+
</button>`
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
<div hidden=${tab !== 'note'}><${NotePanel} busy=${busy} onLog=${log} /></div>
|
|
304
|
+
<div hidden=${tab !== 'sketch'}><${SketchPanel} busy=${busy} onLog=${log} /></div>
|
|
305
|
+
</section>
|
|
306
|
+
</div>
|
|
307
|
+
<${RecentList}
|
|
308
|
+
entryId=${lastEntryId}
|
|
309
|
+
version=${version}
|
|
310
|
+
queue=${queue}
|
|
311
|
+
manoeuvreLabels=${manoeuvreLabels}
|
|
312
|
+
onDelete=${deleteEvent}
|
|
313
|
+
onDiscard=${discard}
|
|
314
|
+
onComment=${(event) => setCommenting({ event })}
|
|
315
|
+
/>
|
|
316
|
+
</main>
|
|
317
|
+
<${Toast}
|
|
318
|
+
toast=${toast}
|
|
319
|
+
onClose=${() => setToast(null)}
|
|
320
|
+
onUndo=${undo}
|
|
321
|
+
onComment=${() => setCommenting({ ref: toast.ref, what: toast.what })}
|
|
322
|
+
/>
|
|
323
|
+
${
|
|
324
|
+
commenting &&
|
|
325
|
+
html`<${CommentDialog}
|
|
326
|
+
title=${commentTitle}
|
|
327
|
+
initial=${commenting.event?.comment ?? null}
|
|
328
|
+
onCancel=${() => setCommenting(null)}
|
|
329
|
+
onSave=${saveComment}
|
|
330
|
+
/>`
|
|
331
|
+
}
|
|
332
|
+
`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Offline start-up and installation need a service worker, which browsers
|
|
336
|
+
// only allow over HTTPS or on localhost; over plain HTTP the page works as is.
|
|
337
|
+
if ('serviceWorker' in navigator && window.isSecureContext) {
|
|
338
|
+
navigator.serviceWorker.register('sw.js').catch(() => {});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const locale = createLocale(pickLanguage(navigator.languages, location.search));
|
|
342
|
+
document.documentElement.lang = locale.language;
|
|
343
|
+
document.title = locale.t('entry.title');
|
|
344
|
+
|
|
345
|
+
let listener = () => {};
|
|
346
|
+
const journal = createJournal({
|
|
347
|
+
request,
|
|
348
|
+
storage: browserStorage(),
|
|
349
|
+
clock,
|
|
350
|
+
newRef: randomId,
|
|
351
|
+
onChange: (snapshot) => listener(snapshot)
|
|
352
|
+
});
|
|
353
|
+
journal.setListener = (next) => {
|
|
354
|
+
listener = next;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
render(
|
|
358
|
+
html`<${LocaleProvider} value=${locale}><${App} journal=${journal} /></${LocaleProvider}>`,
|
|
359
|
+
document.getElementById('app')
|
|
360
|
+
);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Requests the tablet could not deliver, kept in order until the server is
|
|
2
|
+
// reachable again. Every entry carries a clientRef, so a request that reached
|
|
3
|
+
// the server but whose answer was lost is not logged twice when replayed.
|
|
4
|
+
|
|
5
|
+
const STORAGE_KEY = 'chiplog.outbox';
|
|
6
|
+
|
|
7
|
+
// Worth trying again later: no connection, the server or plugin down, or an
|
|
8
|
+
// access that can be granted meanwhile. Anything else is a refusal that a
|
|
9
|
+
// retry would only repeat.
|
|
10
|
+
export function isTransient(error) {
|
|
11
|
+
const status = error?.status ?? 0;
|
|
12
|
+
return (
|
|
13
|
+
status === 0 ||
|
|
14
|
+
status === 401 ||
|
|
15
|
+
status === 403 ||
|
|
16
|
+
status === 408 ||
|
|
17
|
+
status === 429 ||
|
|
18
|
+
status >= 500
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function memoryStorage() {
|
|
23
|
+
const values = new Map();
|
|
24
|
+
return {
|
|
25
|
+
getItem: (key) => values.get(key) ?? null,
|
|
26
|
+
setItem: (key, value) => values.set(key, String(value))
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createOutbox({ storage, send, onChange = () => {} }) {
|
|
31
|
+
storage ??= memoryStorage();
|
|
32
|
+
let state = { pending: [], failed: [] };
|
|
33
|
+
try {
|
|
34
|
+
const stored = JSON.parse(storage.getItem(STORAGE_KEY) ?? 'null');
|
|
35
|
+
if (stored && Array.isArray(stored.pending) && Array.isArray(stored.failed)) {
|
|
36
|
+
state = stored;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Unreadable storage starts empty.
|
|
40
|
+
}
|
|
41
|
+
let flushing = null;
|
|
42
|
+
|
|
43
|
+
function save() {
|
|
44
|
+
try {
|
|
45
|
+
storage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
46
|
+
} catch {
|
|
47
|
+
// Storage full or unavailable: the queue still lives in memory.
|
|
48
|
+
}
|
|
49
|
+
onChange(snapshot());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function snapshot() {
|
|
53
|
+
return { pending: [...state.pending], failed: [...state.failed] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function run() {
|
|
57
|
+
const sent = [];
|
|
58
|
+
while (state.pending.length > 0) {
|
|
59
|
+
const item = state.pending[0];
|
|
60
|
+
try {
|
|
61
|
+
const result = await send(item);
|
|
62
|
+
state.pending = state.pending.filter((other) => other.ref !== item.ref);
|
|
63
|
+
sent.push({ item, result });
|
|
64
|
+
save();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (isTransient(error)) {
|
|
67
|
+
return { sent, blocked: error };
|
|
68
|
+
}
|
|
69
|
+
state.pending = state.pending.filter((other) => other.ref !== item.ref);
|
|
70
|
+
state.failed.push({
|
|
71
|
+
...item,
|
|
72
|
+
error: { status: error.status ?? 0, code: error.code ?? null, message: error.message }
|
|
73
|
+
});
|
|
74
|
+
save();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { sent, blocked: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
snapshot,
|
|
82
|
+
|
|
83
|
+
get size() {
|
|
84
|
+
return state.pending.length;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// item: { ref, method, path, body, createdAt }
|
|
88
|
+
add(item) {
|
|
89
|
+
state.pending.push(item);
|
|
90
|
+
save();
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// Changes an entry not sent yet, such as a comment added before the
|
|
94
|
+
// connection came back.
|
|
95
|
+
update(ref, change) {
|
|
96
|
+
const item = state.pending.find((other) => other.ref === ref);
|
|
97
|
+
if (!item) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
item.body = { ...item.body, ...change };
|
|
101
|
+
save();
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
remove(ref) {
|
|
106
|
+
const before = state.pending.length + state.failed.length;
|
|
107
|
+
state.pending = state.pending.filter((item) => item.ref !== ref);
|
|
108
|
+
state.failed = state.failed.filter((item) => item.ref !== ref);
|
|
109
|
+
const removed = state.pending.length + state.failed.length < before;
|
|
110
|
+
if (removed) {
|
|
111
|
+
save();
|
|
112
|
+
}
|
|
113
|
+
return removed;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
// One replay at a time; a second call waits for the one under way.
|
|
117
|
+
flush() {
|
|
118
|
+
flushing ??= run().finally(() => {
|
|
119
|
+
flushing = null;
|
|
120
|
+
});
|
|
121
|
+
return flushing;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Records a handwritten note as vector strokes, the format the logbook stores
|
|
2
|
+
// (SPEC §4.4): points in CSS pixels of the canvas, t in milliseconds from the
|
|
3
|
+
// first point of the note, pressure only from a pen.
|
|
4
|
+
|
|
5
|
+
// Rounded through toFixed: multiplying back by a step leaves 38.800000000000004.
|
|
6
|
+
const round = (value, digits) => Number(value.toFixed(digits));
|
|
7
|
+
|
|
8
|
+
export function createStrokeRecorder() {
|
|
9
|
+
let strokes = [];
|
|
10
|
+
let current = null;
|
|
11
|
+
let origin = null;
|
|
12
|
+
|
|
13
|
+
function point(x, y, time, pressure) {
|
|
14
|
+
origin ??= time;
|
|
15
|
+
const recorded = { x: round(x, 1), y: round(y, 1), t: Math.round(time - origin) };
|
|
16
|
+
if (typeof pressure === 'number' && Number.isFinite(pressure)) {
|
|
17
|
+
recorded.pressure = round(Math.min(Math.max(pressure, 0), 1), 2);
|
|
18
|
+
}
|
|
19
|
+
return recorded;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
begin(x, y, time, pressure) {
|
|
24
|
+
current = { points: [point(x, y, time, pressure)] };
|
|
25
|
+
strokes.push(current);
|
|
26
|
+
return current;
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
extend(x, y, time, pressure) {
|
|
30
|
+
if (!current) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const next = point(x, y, time, pressure);
|
|
34
|
+
const last = current.points.at(-1);
|
|
35
|
+
if (last.x !== next.x || last.y !== next.y) {
|
|
36
|
+
current.points.push(next);
|
|
37
|
+
}
|
|
38
|
+
return current;
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
end() {
|
|
42
|
+
current = null;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
undo() {
|
|
46
|
+
current = null;
|
|
47
|
+
strokes.pop();
|
|
48
|
+
if (strokes.length === 0) {
|
|
49
|
+
origin = null;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
clear() {
|
|
54
|
+
strokes = [];
|
|
55
|
+
current = null;
|
|
56
|
+
origin = null;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
get strokes() {
|
|
60
|
+
return strokes;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
isEmpty() {
|
|
64
|
+
return strokes.length === 0;
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
payload({ width, height }) {
|
|
68
|
+
return {
|
|
69
|
+
strokes: strokes.map((stroke) => ({ points: stroke.points.map((p) => ({ ...p })) })),
|
|
70
|
+
width: Math.round(width),
|
|
71
|
+
height: Math.round(height)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pen width for a pressure: a firm stroke is up to twice as thick, and input
|
|
78
|
+
// without pressure (finger, mouse) draws at the base width.
|
|
79
|
+
export function strokeWidth(base, pressure) {
|
|
80
|
+
return typeof pressure === 'number' ? base * (0.5 + pressure * 1.5) : base;
|
|
81
|
+
}
|