@signalk/tracks-plugin 3.1.0 → 3.2.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/public/bin.js ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Deleting tracks, and the recycle bin they go to.
3
+ *
4
+ * Deletes go through the v2 Track API, like every other client's; the bin is
5
+ * this plugin's own, under its router at /plugins/tracks, since the Track API
6
+ * has no restore. Both need an administrator, which the server enforces.
7
+ *
8
+ * Everything that touches the page runs from `start()`, so the pure helpers
9
+ * can be imported and tested without a document.
10
+ */
11
+
12
+ /** Relative, because the server mounts this page under /@signalk/tracks-plugin. */
13
+ const TRACKS_URL = '../../signalk/v2/api/tracks'
14
+ const BIN_URL = '../../plugins/tracks/recycle-bin'
15
+
16
+ /** The label for a track, matching `trackLabel()` on the server and `label()` in tracks.js. */
17
+ export function label({ context, contextName, isSelf }) {
18
+ if (isSelf) {
19
+ return 'Own Ship'
20
+ }
21
+ if (contextName) {
22
+ return `AIS ${contextName}`
23
+ }
24
+ const mmsi = /urn:mrn:imo:mmsi:(\d+)$/.exec(context ?? '')
25
+ return mmsi ? `AIS ${mmsi[1]}` : (context ?? 'No vessel')
26
+ }
27
+
28
+ /** What a bin entry deleted, in the viewer's locale and zone. */
29
+ export function deletedPart({ whole, from, to }) {
30
+ if (whole) {
31
+ return 'The whole track'
32
+ }
33
+ if (from === undefined) {
34
+ return `Everything up to ${when(to)}`
35
+ }
36
+ if (to === undefined) {
37
+ return `Everything from ${when(from)}`
38
+ }
39
+ return `${when(from)} to ${when(to)}`
40
+ }
41
+
42
+ /**
43
+ * The query string for a delete of part of a track, from two `datetime-local`
44
+ * values; undefined when neither is set.
45
+ *
46
+ * The inputs show whole seconds, so `to` reaches the end of its second: a
47
+ * track ending at 12:00:00.5 shows as ending at 12:00:00, and a span "to
48
+ * 12:00:00" taken literally would keep the last point the user meant to
49
+ * delete.
50
+ */
51
+ export function spanQuery(from, to) {
52
+ const params = new URLSearchParams()
53
+ if (from) params.set('from', new Date(from).toISOString())
54
+ if (to) params.set('to', new Date(new Date(to).getTime() + 999).toISOString())
55
+ const query = params.toString()
56
+ return query ? `?${query}` : undefined
57
+ }
58
+
59
+ /** An ISO time as a `datetime-local` value, in the viewer's zone. */
60
+ export function localInput(iso) {
61
+ const at = new Date(iso)
62
+ if (Number.isNaN(at.getTime())) {
63
+ return ''
64
+ }
65
+ const pad = (n) => String(n).padStart(2, '0')
66
+ return (
67
+ `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}` +
68
+ `T${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`
69
+ )
70
+ }
71
+
72
+ /** Why a request was refused, in words a user can act on. */
73
+ export function refusal(status) {
74
+ if (status === 401 || status === 403) {
75
+ return 'This needs an administrator login.'
76
+ }
77
+ if (status === 404) {
78
+ return 'It is no longer there.'
79
+ }
80
+ if (status === 501) {
81
+ return 'The track provider cannot do this.'
82
+ }
83
+ return `The server answered ${status}.`
84
+ }
85
+
86
+ function when(iso) {
87
+ const at = new Date(iso)
88
+ return Number.isNaN(at.getTime()) ? '—' : at.toLocaleString()
89
+ }
90
+
91
+ async function messageOf(response) {
92
+ try {
93
+ const body = await response.json()
94
+ return body?.error ?? body?.message
95
+ } catch {
96
+ return undefined
97
+ }
98
+ }
99
+
100
+ function start() {
101
+ const dialog = document.getElementById('delete-dialog')
102
+ const form = dialog.querySelector('form')
103
+ const error = document.getElementById('delete-error')
104
+ const binStatus = document.getElementById('bin-status')
105
+ const binTable = document.getElementById('bin-table')
106
+ const binBody = binTable.querySelector('tbody')
107
+ let target
108
+
109
+ document.addEventListener('tracks:delete', (event) => {
110
+ target = event.detail
111
+ document.getElementById('delete-title').textContent = `Delete ${target.label}${
112
+ target.name ? ` (imported: ${target.name})` : ''
113
+ }`
114
+ // A recording goes on after it is deleted: what is recorded next is a new
115
+ // track, so "whole" means up to now, and saying so avoids a surprise.
116
+ document.getElementById('delete-whole').textContent = target.id.startsWith(`${target.providerId}:recorded:`)
117
+ ? 'The whole track, up to now'
118
+ : 'The whole track'
119
+ form.elements.scope.value = 'whole'
120
+ form.elements.from.value = localInput(target.from)
121
+ form.elements.to.value = localInput(target.to)
122
+ // A track without times has no part to address by time.
123
+ for (const input of form.querySelectorAll('[value="span"], .span input')) {
124
+ input.disabled = target.from === undefined
125
+ }
126
+ error.hidden = true
127
+ dialog.showModal()
128
+ })
129
+
130
+ form.addEventListener('submit', async (event) => {
131
+ if (event.submitter?.value !== 'delete') {
132
+ return
133
+ }
134
+ event.preventDefault()
135
+ const { scope, from, to } = form.elements
136
+ let query = ''
137
+ if (scope.value === 'span') {
138
+ // Without a bound the request would delete the whole track, which is
139
+ // not what choosing a part asked for.
140
+ const span = spanQuery(from.value, to.value)
141
+ if (span === undefined) {
142
+ error.textContent = 'Give the time the part starts, ends, or both.'
143
+ error.hidden = false
144
+ return
145
+ }
146
+ query = span
147
+ }
148
+ // The server hands out ids already qualified by their provider.
149
+ const id = encodeURIComponent(target.id)
150
+ const url = `${TRACKS_URL}/${id}${query}`
151
+ let response
152
+ try {
153
+ response = await fetch(url, { method: 'DELETE', credentials: 'include' })
154
+ } catch {
155
+ error.textContent = 'Could not reach the server.'
156
+ error.hidden = false
157
+ return
158
+ }
159
+ if (!response.ok) {
160
+ error.textContent = (await messageOf(response)) ?? refusal(response.status)
161
+ error.hidden = false
162
+ return
163
+ }
164
+ dialog.close()
165
+ location.reload()
166
+ })
167
+
168
+ async function act(url, method) {
169
+ let response
170
+ try {
171
+ response = await fetch(url, { method, credentials: 'include' })
172
+ } catch {
173
+ showBin('Could not reach the server.', 'error')
174
+ return
175
+ }
176
+ if (!response.ok) {
177
+ showBin((await messageOf(response)) ?? refusal(response.status), 'error')
178
+ return
179
+ }
180
+ location.reload()
181
+ }
182
+
183
+ function showBin(message, state) {
184
+ binStatus.textContent = message
185
+ if (state) {
186
+ binStatus.dataset.state = state
187
+ } else {
188
+ delete binStatus.dataset.state
189
+ }
190
+ }
191
+
192
+ function cell(text, className) {
193
+ const td = document.createElement('td')
194
+ td.textContent = String(text)
195
+ if (className) td.className = className
196
+ return td
197
+ }
198
+
199
+ function button(text, onClick, className) {
200
+ const b = document.createElement('button')
201
+ b.type = 'button'
202
+ b.textContent = text
203
+ if (className) b.className = className
204
+ b.addEventListener('click', onClick)
205
+ return b
206
+ }
207
+
208
+ function binRow(entry) {
209
+ const tr = document.createElement('tr')
210
+ const name = cell(label(entry))
211
+ if (entry.name) {
212
+ const imported = document.createElement('span')
213
+ imported.className = 'imported'
214
+ imported.textContent = ` (imported: ${entry.name})`
215
+ name.append(imported)
216
+ }
217
+ const actions = document.createElement('td')
218
+ const buttons = document.createElement('div')
219
+ buttons.className = 'actions'
220
+ actions.append(buttons)
221
+ buttons.append(
222
+ button('Restore', () => act(`${BIN_URL}/${entry.id}/restore`, 'POST')),
223
+ button(
224
+ 'Delete now',
225
+ () => {
226
+ if (confirm('Delete this for good? It cannot be restored afterwards.')) {
227
+ void act(`${BIN_URL}/${entry.id}`, 'DELETE')
228
+ }
229
+ },
230
+ 'danger',
231
+ ),
232
+ )
233
+ tr.append(
234
+ name,
235
+ cell(deletedPart(entry)),
236
+ cell(when(entry.deletedAt)),
237
+ cell(when(entry.purgeAt)),
238
+ cell(entry.pointCount, 'num'),
239
+ actions,
240
+ )
241
+ return tr
242
+ }
243
+
244
+ async function loadBin() {
245
+ let response
246
+ try {
247
+ response = await fetch(BIN_URL, { credentials: 'include', signal: AbortSignal.timeout(30_000) })
248
+ } catch {
249
+ showBin('Could not reach the server.', 'error')
250
+ return
251
+ }
252
+ if (!response.ok) {
253
+ showBin(
254
+ response.status === 401 || response.status === 403
255
+ ? 'Sign in as an administrator to see the recycle bin.'
256
+ : refusal(response.status),
257
+ 'error',
258
+ )
259
+ return
260
+ }
261
+ let entries
262
+ try {
263
+ entries = await response.json()
264
+ } catch {
265
+ entries = undefined
266
+ }
267
+ if (!Array.isArray(entries)) {
268
+ showBin('The server sent a response this page could not read.', 'error')
269
+ return
270
+ }
271
+ if (entries.length === 0) {
272
+ showBin('The recycle bin is empty.')
273
+ return
274
+ }
275
+ binBody.replaceChildren(...entries.map(binRow))
276
+ binTable.hidden = false
277
+ showBin(`${entries.length} deleted track${entries.length === 1 ? '' : 's'} or parts, newest first.`)
278
+ }
279
+
280
+ void loadBin()
281
+ }
282
+
283
+ if (typeof document !== 'undefined') {
284
+ start()
285
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Importing GPX files as tracks.
3
+ *
4
+ * The file goes to this plugin's own route, which reads it with the same GPX
5
+ * parser the export uses: the page ships with no bundler and cannot reach it.
6
+ * Storing needs write access, which the server enforces.
7
+ */
8
+ import { refusal } from './bin.js'
9
+
10
+ /** Relative, because the server mounts this page under /@signalk/tracks-plugin. */
11
+ const IMPORT_URL = '../../plugins/tracks/imports'
12
+
13
+ /** What an import came to, in words. */
14
+ export function imported({ ids, skippedPoints }) {
15
+ const count = `Imported ${ids.length} track${ids.length === 1 ? '' : 's'}.`
16
+ return skippedPoints > 0
17
+ ? `${count} ${skippedPoints} point${skippedPoints === 1 ? '' : 's'} without a time left out.`
18
+ : count
19
+ }
20
+
21
+ async function messageOf(response) {
22
+ try {
23
+ const body = await response.json()
24
+ return body?.message ?? body?.error
25
+ } catch {
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ export function start() {
31
+ const form = document.getElementById('import-form')
32
+ const status = document.getElementById('import-status')
33
+ const button = form.querySelector('button[type="submit"]')
34
+
35
+ function show(message, state) {
36
+ // A failed upload can be tried again; a successful one reloads the page.
37
+ if (state === 'error') {
38
+ button.disabled = false
39
+ }
40
+ status.textContent = message
41
+ status.hidden = false
42
+ if (state) {
43
+ status.dataset.state = state
44
+ } else {
45
+ delete status.dataset.state
46
+ }
47
+ }
48
+
49
+ form.addEventListener('submit', async (event) => {
50
+ event.preventDefault()
51
+ // Each request stores the file anew, so a second click while one is in
52
+ // flight would import every track in it twice.
53
+ if (button.disabled) {
54
+ return
55
+ }
56
+ const file = form.elements.file.files[0]
57
+ if (!file) {
58
+ show('Choose a GPX file first.', 'error')
59
+ return
60
+ }
61
+ const query = form.elements.self.checked ? '?self=true' : ''
62
+ button.disabled = true
63
+ show('Importing…')
64
+ let response
65
+ try {
66
+ response = await fetch(`${IMPORT_URL}${query}`, {
67
+ method: 'POST',
68
+ credentials: 'include',
69
+ headers: { 'Content-Type': 'application/gpx+xml' },
70
+ body: file,
71
+ })
72
+ } catch {
73
+ show('Could not reach the server.', 'error')
74
+ return
75
+ }
76
+ if (!response.ok) {
77
+ show((await messageOf(response)) ?? refusal(response.status), 'error')
78
+ return
79
+ }
80
+ let body
81
+ try {
82
+ body = await response.json()
83
+ } catch {
84
+ body = undefined
85
+ }
86
+ if (!Array.isArray(body?.ids)) {
87
+ show('The server sent a response this page could not read.', 'error')
88
+ return
89
+ }
90
+ show(imported(body))
91
+ // Reloaded so the list, which shows every import, includes this one.
92
+ setTimeout(() => location.reload(), 1500)
93
+ })
94
+ }
95
+
96
+ if (typeof document !== 'undefined') {
97
+ start()
98
+ }
package/public/index.html CHANGED
@@ -21,11 +21,63 @@
21
21
  <th scope="col">To</th>
22
22
  <th scope="col" class="num">Points</th>
23
23
  <th scope="col">Export</th>
24
+ <th scope="col"><span class="sr-only">Delete</span></th>
24
25
  </tr>
25
26
  </thead>
26
27
  <tbody></tbody>
27
28
  </table>
29
+ <section id="import" aria-labelledby="import-title">
30
+ <h2 id="import-title">Import</h2>
31
+ <form id="import-form">
32
+ <label>GPX file <input type="file" name="file" accept=".gpx,application/gpx+xml" required /></label>
33
+ <label><input type="checkbox" name="self" /> Tracks of my own vessel</label>
34
+ <button type="submit">Import</button>
35
+ </form>
36
+ <p id="import-status" role="status" hidden></p>
37
+ </section>
38
+ <section id="bin" aria-labelledby="bin-title">
39
+ <h2 id="bin-title">Recycle bin</h2>
40
+ <p id="bin-status" role="status">Loading…</p>
41
+ <table id="bin-table" hidden>
42
+ <thead>
43
+ <tr>
44
+ <th scope="col">Track</th>
45
+ <th scope="col">Deleted part</th>
46
+ <th scope="col">Deleted</th>
47
+ <th scope="col">Purged</th>
48
+ <th scope="col" class="num">Points</th>
49
+ <th scope="col"><span class="sr-only">Actions</span></th>
50
+ </tr>
51
+ </thead>
52
+ <tbody></tbody>
53
+ </table>
54
+ </section>
28
55
  </main>
56
+ <dialog id="delete-dialog">
57
+ <form method="dialog">
58
+ <h2 id="delete-title">Delete track</h2>
59
+ <fieldset>
60
+ <legend>What to delete</legend>
61
+ <label
62
+ ><input type="radio" name="scope" value="whole" checked />
63
+ <span id="delete-whole">The whole track</span></label
64
+ >
65
+ <label><input type="radio" name="scope" value="span" /> Only the part recorded</label>
66
+ <div class="span">
67
+ <label>from <input type="datetime-local" name="from" step="1" /></label>
68
+ <label>to <input type="datetime-local" name="to" step="1" /></label>
69
+ </div>
70
+ </fieldset>
71
+ <p>It goes to the recycle bin, where it can be restored until it is purged.</p>
72
+ <p id="delete-error" class="error" role="alert" hidden></p>
73
+ <menu>
74
+ <button value="cancel" formnovalidate>Cancel</button>
75
+ <button value="delete" class="danger">Delete</button>
76
+ </menu>
77
+ </form>
78
+ </dialog>
29
79
  <script type="module" src="./tracks.js"></script>
80
+ <script type="module" src="./bin.js"></script>
81
+ <script type="module" src="./import.js"></script>
30
82
  </body>
31
83
  </html>
package/public/style.css CHANGED
@@ -47,3 +47,81 @@ tbody tr:hover {
47
47
  .self {
48
48
  font-weight: 600;
49
49
  }
50
+
51
+ .sr-only {
52
+ position: absolute;
53
+ width: 1px;
54
+ height: 1px;
55
+ overflow: hidden;
56
+ clip-path: inset(50%);
57
+ white-space: nowrap;
58
+ }
59
+
60
+ h2 {
61
+ font-size: 1.1rem;
62
+ margin: 2rem 0 0.25rem;
63
+ }
64
+
65
+ #import-form {
66
+ display: flex;
67
+ flex-wrap: wrap;
68
+ align-items: center;
69
+ gap: 0.5rem 1rem;
70
+ }
71
+
72
+ #bin-status,
73
+ #import-status {
74
+ color: GrayText;
75
+ margin: 0 0 1rem;
76
+ }
77
+
78
+ .error,
79
+ #bin-status[data-state='error'],
80
+ #import-status[data-state='error'] {
81
+ color: crimson;
82
+ }
83
+
84
+ .imported {
85
+ color: GrayText;
86
+ }
87
+
88
+ .actions {
89
+ display: flex;
90
+ flex-wrap: wrap;
91
+ gap: 0.5rem;
92
+ }
93
+
94
+ .danger {
95
+ color: crimson;
96
+ }
97
+
98
+ dialog {
99
+ max-width: min(32rem, calc(100vw - 2rem));
100
+ }
101
+
102
+ dialog h2 {
103
+ margin-top: 0;
104
+ }
105
+
106
+ dialog fieldset {
107
+ display: grid;
108
+ gap: 0.5rem;
109
+ border: none;
110
+ padding: 0;
111
+ margin: 0 0 1rem;
112
+ }
113
+
114
+ dialog .span {
115
+ display: flex;
116
+ flex-wrap: wrap;
117
+ gap: 0.5rem 1rem;
118
+ margin-left: 1.5rem;
119
+ }
120
+
121
+ dialog menu {
122
+ display: flex;
123
+ justify-content: flex-end;
124
+ gap: 0.5rem;
125
+ padding: 0;
126
+ margin: 0;
127
+ }