@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/tracks.js CHANGED
@@ -25,6 +25,9 @@
25
25
  const WINDOW_V2 = 'P30D'
26
26
  const WINDOW_V1 = '30d'
27
27
 
28
+ /** This plugin's id, which the server stamps on its tracks as `providerId`. */
29
+ const PROVIDER_ID = 'tracks'
30
+
28
31
  /**
29
32
  * Ceiling on the list request.
30
33
  *
@@ -36,6 +39,10 @@ const REQUEST_TIMEOUT_MS = 30_000
36
39
 
37
40
  /** Relative, because the server mounts this under /@signalk/tracks-plugin. */
38
41
  const TRACKS_URL = `../../signalk/v2/api/tracks?geometry=false&duration=${WINDOW_V2}`
42
+ /** One track by its id, on a server that serves them so. */
43
+ const TRACK_URL = '../../signalk/v2/api/tracks/'
44
+ /** Every import, in the shape of the list's rows; the window above misses old and untimed ones. */
45
+ const IMPORTS_URL = '../../plugins/tracks/imports'
39
46
 
40
47
  const status = document.getElementById('status')
41
48
  const table = document.getElementById('tracks')
@@ -56,8 +63,8 @@ function label({ context, contextName, isSelf }) {
56
63
  if (contextName) {
57
64
  return `AIS ${contextName}`
58
65
  }
59
- const mmsi = /urn:mrn:imo:mmsi:(\d+)$/.exec(context)
60
- return mmsi ? `AIS ${mmsi[1]}` : context
66
+ const mmsi = /urn:mrn:imo:mmsi:(\d+)$/.exec(context ?? '')
67
+ return mmsi ? `AIS ${mmsi[1]}` : (context ?? 'No vessel')
61
68
  }
62
69
 
63
70
  /** ISO-8601 from the API, rendered in the viewer's own locale and zone. */
@@ -73,16 +80,48 @@ function row(properties) {
73
80
  if (properties.isSelf) {
74
81
  name.className = 'self'
75
82
  }
83
+ // An imported track names the vessel it belongs to and has a name of its
84
+ // own; without the second it reads exactly like that vessel's recording.
85
+ if (typeof properties.name === 'string') {
86
+ const imported = document.createElement('span')
87
+ imported.className = 'imported'
88
+ imported.textContent = ` (imported: ${properties.name})`
89
+ name.append(imported)
90
+ }
76
91
  tr.append(
77
92
  name,
78
93
  cell(when(properties.from)),
79
94
  cell(when(properties.to)),
80
95
  cell(properties.pointCount, 'num'),
81
96
  exportCell(properties),
97
+ deleteCell(properties),
82
98
  )
83
99
  return tr
84
100
  }
85
101
 
102
+ /**
103
+ * A button asking `bin.js` to delete this track.
104
+ *
105
+ * Handed over as an event rather than handled here, so the list stays a page
106
+ * that only reads; a track that cannot be addressed gets none.
107
+ */
108
+ function deleteCell(properties) {
109
+ const td = document.createElement('td')
110
+ // The server names a track `providerId:trackId`; one that hands out the
111
+ // provider's own ids has no route to delete them by.
112
+ const { id, providerId } = properties
113
+ if (typeof id === 'string' && typeof providerId === 'string' && id.startsWith(`${providerId}:`)) {
114
+ const button = document.createElement('button')
115
+ button.type = 'button'
116
+ button.textContent = 'Delete…'
117
+ button.addEventListener('click', () => {
118
+ document.dispatchEvent(new CustomEvent('tracks:delete', { detail: { ...properties, label: label(properties) } }))
119
+ })
120
+ td.append(button)
121
+ }
122
+ return td
123
+ }
124
+
86
125
  /**
87
126
  * A link that downloads this track as GPX.
88
127
  *
@@ -90,13 +129,27 @@ function row(properties) {
90
129
  * response with a Content-Disposition on it, and the server sets one. Doing it
91
130
  * by hand would mean holding a whole track in memory to build a blob.
92
131
  */
93
- function exportCell({ context, isSelf }) {
132
+ function exportCell(properties) {
133
+ const { providerId, context, isSelf } = properties
94
134
  const td = document.createElement('td')
95
135
  const link = document.createElement('a')
96
- // `self` resolves server-side; using it keeps the URL short and avoids
97
- // guessing how the own vessel's context should be spelled.
98
- const vessel = isSelf ? 'self/track.gpx' : `vessels/${encodeURIComponent(context)}/track.gpx`
99
- link.href = `../../signalk/v1/api/${vessel}?duration=${WINDOW_V1}`
136
+ const trackId = localId(properties)
137
+ // An import is exported as it was imported, never as the recording of the
138
+ // vessel it names; v1 does not know imports at all. Only this plugin's own:
139
+ // another provider's ids mean nothing to its route.
140
+ if (typeof trackId === 'string' && trackId.startsWith('imported:')) {
141
+ if (providerId !== undefined && providerId !== PROVIDER_ID) {
142
+ return td
143
+ }
144
+ link.href = `../../plugins/tracks/imports/${encodeURIComponent(trackId)}/track.gpx`
145
+ } else if (typeof context === 'string') {
146
+ // `self` resolves server-side; using it keeps the URL short and avoids
147
+ // guessing how the own vessel's context should be spelled.
148
+ const vessel = isSelf ? 'self/track.gpx' : `vessels/${encodeURIComponent(context)}/track.gpx`
149
+ link.href = `../../signalk/v1/api/${vessel}?duration=${WINDOW_V1}`
150
+ } else {
151
+ return td
152
+ }
100
153
  link.textContent = 'GPX'
101
154
  link.rel = 'nofollow'
102
155
  td.append(link)
@@ -123,14 +176,68 @@ function show(message, state) {
123
176
  }
124
177
  }
125
178
 
179
+ /**
180
+ * Every imported track, or none when the server cannot list them: one that
181
+ * predates the route, or keeps it to administrators. The list then shows what
182
+ * the Track API returns, as it always has.
183
+ */
184
+ async function allImports(signal) {
185
+ try {
186
+ const response = await fetch(IMPORTS_URL, { credentials: 'include', signal })
187
+ const body = response.ok ? await response.json() : undefined
188
+ return Array.isArray(body) ? body : []
189
+ } catch {
190
+ return []
191
+ }
192
+ }
193
+
194
+ /**
195
+ * A track's id without the provider prefix, which a server older than the
196
+ * Track API's write routes leaves off; the import listing always carries it.
197
+ */
198
+ function localId(properties) {
199
+ const { id, providerId } = properties ?? {}
200
+ return typeof id === 'string' && typeof providerId === 'string' && id.startsWith(`${providerId}:`)
201
+ ? id.slice(providerId.length + 1)
202
+ : id
203
+ }
204
+
205
+ /**
206
+ * Whether the server names tracks `providerId:trackId`. Its listing shows it;
207
+ * with nothing listed, asking it for one import by that name does.
208
+ */
209
+ async function namesByProvider(listed, unlisted, signal) {
210
+ if (listed.length > 0) {
211
+ return listed.some(
212
+ (properties) =>
213
+ typeof properties?.providerId === 'string' &&
214
+ typeof properties.id === 'string' &&
215
+ properties.id.startsWith(`${properties.providerId}:`),
216
+ )
217
+ }
218
+ if (unlisted.length === 0) {
219
+ return false
220
+ }
221
+ try {
222
+ const response = await fetch(`${TRACK_URL}${encodeURIComponent(unlisted[0].id)}`, {
223
+ credentials: 'include',
224
+ signal,
225
+ })
226
+ return response.ok
227
+ } catch {
228
+ return false
229
+ }
230
+ }
231
+
126
232
  async function load() {
127
- let response
128
233
  // Bounded, because an unsettled fetch is the one failure the page cannot
129
234
  // recover from: every other path ends in an error message, while a stalled
130
235
  // server or proxy leaves it on "Loading…" indefinitely, which reads as a
131
236
  // hang rather than a failure. The signal stays armed through json() below,
132
237
  // so a response that stops mid-body is caught too.
133
238
  const deadline = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
239
+ const imports = allImports(deadline)
240
+ let response
134
241
  try {
135
242
  response = await fetch(TRACKS_URL, { credentials: 'include', signal: deadline })
136
243
  } catch {
@@ -171,13 +278,24 @@ async function load() {
171
278
  }
172
279
  // Individual entries are still skipped rather than thrown on: one malformed
173
280
  // feature should not blank a list that is otherwise fine.
174
- const features = body.features
281
+ const listed = body.features.map((feature) => feature?.properties)
282
+ const ids = new Set(listed.map(localId))
283
+ const unlisted = (await imports).filter((properties) => !ids.has(localId(properties)))
284
+ // A server older than the Track API's write routes names tracks without
285
+ // their provider and cannot delete by id, so the imports added here follow
286
+ // suit there rather than offer a delete that cannot work.
287
+ const older = (await namesByProvider(listed, unlisted, deadline))
288
+ ? unlisted
289
+ : unlisted.map((properties) => ({ ...properties, id: localId(properties), providerId: undefined }))
175
290
  // Newest first: the track someone came to look at is almost always the one
176
291
  // that just finished.
177
- const sorted = features
178
- .map((feature) => feature?.properties)
292
+ const sorted = [...listed, ...older]
293
+ // An import may name no vessel; it is still listed, so it can be deleted.
179
294
  .filter(
180
- (properties) => properties && typeof properties.context === 'string' && Number.isFinite(properties.pointCount),
295
+ (properties) =>
296
+ properties &&
297
+ (typeof properties.context === 'string' || typeof properties.name === 'string') &&
298
+ Number.isFinite(properties.pointCount),
181
299
  )
182
300
  .sort((a, b) => String(b.to ?? '').localeCompare(String(a.to ?? '')))
183
301
 
@@ -190,7 +308,8 @@ async function load() {
190
308
 
191
309
  tbody.replaceChildren(...sorted.map(row))
192
310
  table.hidden = false
193
- show(`${sorted.length} track${sorted.length === 1 ? '' : 's'} in the last 30 days.`)
311
+ const tracks = `${sorted.length} track${sorted.length === 1 ? '' : 's'}`
312
+ show(older.length > 0 ? `${tracks}: the last 30 days, and every import.` : `${tracks} in the last 30 days.`)
194
313
  }
195
314
 
196
315
  void load()