@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/README.md +63 -0
- package/dist/asyncTrackStore.d.ts +24 -1
- package/dist/bin.js +285 -0
- package/dist/e2e.test-utils.d.ts +16 -2
- package/dist/gpx.d.ts +11 -2
- package/dist/gpxExport.test.d.ts +1 -0
- package/dist/gpxImport.test.d.ts +1 -0
- package/dist/harness.test-utils.d.ts +11 -0
- package/dist/import.js +98 -0
- package/dist/importedTracks.d.ts +54 -0
- package/dist/importedTracks.test.d.ts +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.html +52 -0
- package/dist/index.js +802 -97
- package/dist/index.js.map +1 -1
- package/dist/reconcile.d.ts +11 -0
- package/dist/recycleBin.d.ts +20 -0
- package/dist/recycleBin.test.d.ts +1 -0
- package/dist/recycleBinApi.test.d.ts +1 -0
- package/dist/sqliteStore.d.ts +58 -1
- package/dist/store.d.ts +38 -1
- package/dist/style.css +78 -0
- package/dist/trackApi.d.ts +61 -5
- package/dist/trackProvider.d.ts +4 -0
- package/dist/trackStoreProtocol.d.ts +1 -1
- package/dist/trackStoreWorker.js +371 -1
- package/dist/trackStoreWorker.js.map +1 -1
- package/dist/tracks.js +132 -13
- package/dist/types.d.ts +74 -0
- package/dist/upload.d.ts +19 -0
- package/dist/{utils-CGIGp4Xf.js → utils-C0VkiidM.js} +32 -2
- package/dist/{utils-CGIGp4Xf.js.map → utils-C0VkiidM.js.map} +1 -1
- package/dist/utils.d.ts +14 -0
- package/docs/history-and-storage.md +33 -0
- package/package.json +1 -1
- package/public/bin.js +285 -0
- package/public/import.js +98 -0
- package/public/index.html +52 -0
- package/public/style.css +78 -0
- package/public/tracks.js +132 -13
package/README.md
CHANGED
|
@@ -34,6 +34,69 @@ The own vessel is kept indefinitely by default. Other vessels are kept for 30 da
|
|
|
34
34
|
fix — a harbour puts hundreds of AIS targets past a receiver in a day, and keeping every one of them
|
|
35
35
|
forever is rarely what anybody wants. Both are settings.
|
|
36
36
|
|
|
37
|
+
## Imported tracks
|
|
38
|
+
|
|
39
|
+
A client can store a track of its own, a GPX from a friend or a passage logged by another
|
|
40
|
+
device, with `POST /signalk/v2/api/tracks` (Signal K server with
|
|
41
|
+
[#3038](https://github.com/SignalK/signalk-server/pull/3038)). The body is a GeoJSON
|
|
42
|
+
Feature, the shape a v2 query returns; the plugin answers with an id such as
|
|
43
|
+
`tracks:imported:0f0f2a1e-…`, and `GET` or `DELETE /signalk/v2/api/tracks/<id>` fetch or
|
|
44
|
+
delete it. Storing needs write access; deleting needs administrator rights, or write
|
|
45
|
+
access on a server with security turned off.
|
|
46
|
+
|
|
47
|
+
An imported track is kept apart from the recording. It is listed by v2 queries like any
|
|
48
|
+
other track, filtered by vessel, time window and area, but as a feature of its own even when
|
|
49
|
+
it names a vessel the plugin also records. It is never merged with a history provider's data,
|
|
50
|
+
never removed by the retention settings, and never returned by the v1 routes, which answer
|
|
51
|
+
where vessels are now. Its own name and any other properties it was posted with come back as
|
|
52
|
+
posted.
|
|
53
|
+
|
|
54
|
+
A track needs times (`coordTimes`) or a vessel (`context`). One with neither is refused:
|
|
55
|
+
it would match no time window, and a query across every vessel needs one, so it could
|
|
56
|
+
only be found again by its id.
|
|
57
|
+
|
|
58
|
+
### Importing a GPX file
|
|
59
|
+
|
|
60
|
+
The Tracks webapp imports a GPX file from the **Import** form, and a client can do the same
|
|
61
|
+
with `POST /plugins/tracks/imports`, the file as the request body with `Content-Type:
|
|
62
|
+
application/gpx+xml`. Each track in the file
|
|
63
|
+
becomes an imported track as above. Ticking **Tracks of my own vessel** (`?self=true`) files
|
|
64
|
+
them under the own vessel; otherwise a track names a vessel only if the file says which, as
|
|
65
|
+
the plugin's own GPX export does.
|
|
66
|
+
|
|
67
|
+
A file with a track that cannot be kept is refused whole: if any track in it has neither times
|
|
68
|
+
nor a vessel, nothing is stored and the reason is shown. In a timed track, points without a time are
|
|
69
|
+
skipped, and the answer says how many. The route needs write access on a Signal K server that
|
|
70
|
+
lets a plugin say so, and administrator rights on an older one.
|
|
71
|
+
|
|
72
|
+
The webapp lists every imported track, however old and whether or not it has times, next to
|
|
73
|
+
the last 30 days of recorded ones, so a passage imported from years ago can be found there. It
|
|
74
|
+
can be deleted there on a Signal K server that deletes tracks by id. `GET /plugins/tracks/imports` returns every imported track, and no recorded ones.
|
|
75
|
+
|
|
76
|
+
The Tracks webapp exports an imported track as GPX from its row, as it was imported. A client
|
|
77
|
+
can download the same file from `GET /plugins/tracks/imports/<id>/track.gpx`, with the id less
|
|
78
|
+
its `tracks:` prefix: under the track's own name, and without times if it had none.
|
|
79
|
+
|
|
80
|
+
## Deleting tracks
|
|
81
|
+
|
|
82
|
+
`DELETE /signalk/v2/api/tracks/<id>` deletes a track, and with `from`, `to` or both only the
|
|
83
|
+
part recorded between them (on a Signal K server that accepts them; an older one refuses the
|
|
84
|
+
request rather than deleting the whole track). Deleting needs administrator rights, or write
|
|
85
|
+
access on a server with security turned off. The Tracks webapp offers both from each row.
|
|
86
|
+
|
|
87
|
+
A recorded track has the id `tracks:recorded:<vessel context>`. Deleting it whole removes
|
|
88
|
+
what was recorded up to now; the recording goes on, and what it records next is kept. With a
|
|
89
|
+
history provider installed the deleted span is hidden in what the provider returns too, since
|
|
90
|
+
the History API has no way to delete or hide data and the next query would otherwise fill it
|
|
91
|
+
back in.
|
|
92
|
+
|
|
93
|
+
Nothing deleted is gone at once. It goes to a recycle bin, listed in the Tracks webapp, where it
|
|
94
|
+
can be restored or deleted for good, until it is purged after the number of days set in the
|
|
95
|
+
plugin settings (180 by default). The retention settings above do not reach into the bin, but
|
|
96
|
+
they apply again to what is restored: an AIS vessel restored after its last fix has aged past
|
|
97
|
+
the retention is dropped at the next prune. Once a recorded span is purged its points are gone,
|
|
98
|
+
but the span stays hidden in the provider's data for good.
|
|
99
|
+
|
|
37
100
|
## Glitch filtering
|
|
38
101
|
|
|
39
102
|
A receiver occasionally reports a position far from the vessel — a bad almanac, a multipath
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TrackStore } from './store.js';
|
|
2
2
|
import { SqliteStoreConfig } from './sqliteStore.js';
|
|
3
|
-
import { Debug, Context, LatLngTuple, TimeWindow, TrackParams } from './types.js';
|
|
3
|
+
import { Debug, Context, ImportedTrack, ImportFilter, LatLngTuple, TimeWindow, TrackParams } from './types.js';
|
|
4
4
|
import { TrackQuery } from './timeWindow.js';
|
|
5
5
|
interface Limits {
|
|
6
6
|
maxItems?: number;
|
|
@@ -41,6 +41,14 @@ export declare class AsyncTrackStore implements TrackStore {
|
|
|
41
41
|
private resumeIfDrained;
|
|
42
42
|
private fail;
|
|
43
43
|
private enqueue;
|
|
44
|
+
/**
|
|
45
|
+
* A write whose caller waits for the commit: an imported track, or its
|
|
46
|
+
* deletion. Admitted outside the recording budget, which is sized for
|
|
47
|
+
* positions; an import of a few megabytes would otherwise pause recording,
|
|
48
|
+
* and then be dropped like a position. Refused once writes have failed,
|
|
49
|
+
* since that state lasts until a restart.
|
|
50
|
+
*/
|
|
51
|
+
private acknowledged;
|
|
44
52
|
private schedule;
|
|
45
53
|
private pump;
|
|
46
54
|
private receive;
|
|
@@ -57,6 +65,21 @@ export declare class AsyncTrackStore implements TrackStore {
|
|
|
57
65
|
}[]>;
|
|
58
66
|
getFilteredTracks(params: TrackParams, selfPosition?: LatLngTuple, _debug?: Debug, query?: TrackQuery): Promise<import('./types.js').TrackCollection>;
|
|
59
67
|
getFilteredTimedTracks(params: TrackParams, selfPosition?: LatLngTuple, _debug?: Debug, query?: TrackQuery): Promise<import('./types.js').TimedTrackCollection>;
|
|
68
|
+
storeImport(track: ImportedTrack): Promise<void>;
|
|
69
|
+
binRecorded(context: Context, from: number | undefined, to: number, deletedAt: number, keepEmpty: boolean): Promise<{
|
|
70
|
+
id?: number;
|
|
71
|
+
known: boolean;
|
|
72
|
+
}>;
|
|
73
|
+
binImport(importId: string, span: {
|
|
74
|
+
from?: number;
|
|
75
|
+
to?: number;
|
|
76
|
+
} | undefined, deletedAt: number): Promise<number | undefined>;
|
|
77
|
+
restoreFromBin(id: number): Promise<import('./types.js').RestoreResult>;
|
|
78
|
+
purgeFromBin(id: number): Promise<boolean>;
|
|
79
|
+
binEntries(): Promise<import('./types.js').BinEntry[]>;
|
|
80
|
+
deletedSpans(): Promise<import('./types.js').RecordedSpan[]>;
|
|
81
|
+
getImport(id: string): Promise<ImportedTrack | undefined>;
|
|
82
|
+
findImports(filter?: ImportFilter): Promise<ImportedTrack[]>;
|
|
60
83
|
close(): Promise<void>;
|
|
61
84
|
}
|
|
62
85
|
export {};
|
package/dist/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
|
+
}
|
package/dist/e2e.test-utils.d.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** QuestDB endpoint used by the history-provider tier. */
|
|
2
2
|
export declare const QUESTDB_URL: string;
|
|
3
|
+
export interface RequestOptions {
|
|
4
|
+
method?: string;
|
|
5
|
+
/** Sent as JSON, unless `contentType` says it is already text of that type. */
|
|
6
|
+
body?: unknown;
|
|
7
|
+
contentType?: string;
|
|
8
|
+
}
|
|
3
9
|
export interface E2EServer {
|
|
4
10
|
url: string;
|
|
5
11
|
configDir: string;
|
|
@@ -12,14 +18,22 @@ export interface E2EServer {
|
|
|
12
18
|
*
|
|
13
19
|
* The status is what distinguishes "no provider registered" (501) from an
|
|
14
20
|
* answered query, which is the thing a provider registration test is about.
|
|
21
|
+
* `init` sends another method, with `body` as JSON. A body that is not JSON,
|
|
22
|
+
* as an older server's 404 page is, comes back as its text.
|
|
15
23
|
*/
|
|
16
|
-
apiV2: (path: string) => Promise<{
|
|
24
|
+
apiV2: (path: string, init?: RequestOptions) => Promise<{
|
|
25
|
+
status: number;
|
|
26
|
+
body: unknown;
|
|
27
|
+
}>;
|
|
28
|
+
/** As `apiV2`, for a path under this plugin's own router at /plugins/tracks. */
|
|
29
|
+
plugin: (path: string, init?: RequestOptions) => Promise<{
|
|
17
30
|
status: number;
|
|
18
31
|
body: unknown;
|
|
19
32
|
}>;
|
|
20
33
|
/** The server's own vessel context, as it resolved it. */
|
|
21
34
|
selfContext: string;
|
|
22
|
-
|
|
35
|
+
/** Stop the server, resolving once it has exited and its config dir is gone. */
|
|
36
|
+
stop: () => Promise<void>;
|
|
23
37
|
}
|
|
24
38
|
export interface E2EOptions {
|
|
25
39
|
/** Plugin configuration written to plugin-config-data/tracks.json. */
|
package/dist/gpx.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TimedPosition } from './types.js';
|
|
1
|
+
import { LatLngTuple, TimedPosition } from './types.js';
|
|
2
2
|
/** Identity of the vessel a track belongs to, as far as it is known. */
|
|
3
3
|
export interface GpxTrackIdentity {
|
|
4
4
|
/** Display label, e.g. `Own Ship` or `AIS Ariadne`. Becomes `<name>`. */
|
|
@@ -10,6 +10,15 @@ export interface GpxTrack extends GpxTrackIdentity {
|
|
|
10
10
|
/** Segments as `segment()` produces them: a recording gap starts a new one. */
|
|
11
11
|
segments: TimedPosition[][];
|
|
12
12
|
}
|
|
13
|
+
/** A point to write; one without a time is written without `<time>`. */
|
|
14
|
+
export interface GpxPoint {
|
|
15
|
+
position: LatLngTuple;
|
|
16
|
+
timestamp?: number;
|
|
17
|
+
}
|
|
18
|
+
/** A track to write: a recorded one, or an import that may carry no times. */
|
|
19
|
+
export interface GpxExportTrack extends GpxTrackIdentity {
|
|
20
|
+
segments: GpxPoint[][];
|
|
21
|
+
}
|
|
13
22
|
/**
|
|
14
23
|
* Serialise tracks as a GPX 1.1 document.
|
|
15
24
|
*
|
|
@@ -23,7 +32,7 @@ export interface GpxTrack extends GpxTrackIdentity {
|
|
|
23
32
|
* identity; carrying the context means ours can, while staying readable by
|
|
24
33
|
* anything else, since `<extensions>` is ignorable by specification.
|
|
25
34
|
*/
|
|
26
|
-
export declare function toGpx(tracks:
|
|
35
|
+
export declare function toGpx(tracks: GpxExportTrack[], now?: Date): string;
|
|
27
36
|
/**
|
|
28
37
|
* Parse a GPX document into tracks.
|
|
29
38
|
*
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -117,6 +117,8 @@ export interface HarnessOptions {
|
|
|
117
117
|
* only the earliest of `rows` in each bucket of the asked resolution.
|
|
118
118
|
*/
|
|
119
119
|
aggregatesFirst?: boolean;
|
|
120
|
+
/** Called with every `getValues` query, so a test can see what was asked. */
|
|
121
|
+
onValues?: (query: HistoryValuesQuery) => void;
|
|
120
122
|
/**
|
|
121
123
|
* Reject `getHistoryApi` itself, as the server does when no provider is
|
|
122
124
|
* registered — the default install, and not an outage.
|
|
@@ -136,4 +138,13 @@ export declare function deferred(): {
|
|
|
136
138
|
release: () => void;
|
|
137
139
|
wait: Promise<void>;
|
|
138
140
|
};
|
|
141
|
+
export interface HistoryValuesQuery {
|
|
142
|
+
from: {
|
|
143
|
+
toString: () => string;
|
|
144
|
+
};
|
|
145
|
+
to: {
|
|
146
|
+
toString: () => string;
|
|
147
|
+
};
|
|
148
|
+
resolution?: number;
|
|
149
|
+
}
|
|
139
150
|
export declare function createHarness(options?: HarnessOptions): TestHarness;
|
package/dist/import.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { GpxTrack } from './gpx.js';
|
|
2
|
+
import { TrackImport } from './trackApi.js';
|
|
3
|
+
import { GeoBounds, ImportedTrack, TimedPosition, TimeWindow } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Prefix of every imported track's id. It keeps imported and recorded tracks
|
|
6
|
+
* in separate id spaces, so no import can ever address a recorded track.
|
|
7
|
+
*/
|
|
8
|
+
export declare const IMPORTED_PREFIX = "imported:";
|
|
9
|
+
export declare const isImportedId: (id: string) => boolean;
|
|
10
|
+
/**
|
|
11
|
+
* The track the server handed over, as this plugin stores it.
|
|
12
|
+
*
|
|
13
|
+
* Times are sorted within each segment, the way `fromGpx` reads a file: the
|
|
14
|
+
* thinning and window code assume time order, and a segment boundary is a gap
|
|
15
|
+
* in recording, so segments are never merged or reordered.
|
|
16
|
+
*
|
|
17
|
+
* Refuses a track with neither times nor a vessel. Such a track answers no
|
|
18
|
+
* time window, and the server refuses a query across all vessels without one,
|
|
19
|
+
* so it could only ever be found again by an id the client would have to keep.
|
|
20
|
+
*/
|
|
21
|
+
export declare function toImportedTrack(track: TrackImport, id?: string): ImportedTrack;
|
|
22
|
+
/**
|
|
23
|
+
* A track read from a GPX file, as this plugin stores it, with the number of
|
|
24
|
+
* points left out.
|
|
25
|
+
*
|
|
26
|
+
* `fromGpx` dates a point with no `<time>` to 0. A file with no times at all
|
|
27
|
+
* becomes an untimed import; a file with some keeps only its timed points,
|
|
28
|
+
* because an import is timed throughout or not at all, and its times are what
|
|
29
|
+
* let a query find it. `context` names the vessel in place of the file's own.
|
|
30
|
+
* Refused, like any import, when it ends up with neither times nor a vessel.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fromGpxTrack(gpx: GpxTrack, context?: string | undefined, id?: string): {
|
|
33
|
+
track: ImportedTrack;
|
|
34
|
+
skippedPoints: number;
|
|
35
|
+
};
|
|
36
|
+
/** Whether the track's points carry times; an import is timed throughout or not at all. */
|
|
37
|
+
export declare const isTimed: (track: ImportedTrack) => boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The track as one list of points for the shared pipeline, each posted segment
|
|
40
|
+
* after the first starting a new line.
|
|
41
|
+
*
|
|
42
|
+
* Narrowed to the window and clipped per segment, so a line never joins two
|
|
43
|
+
* segments: not where the window empties the start of one, nor through the
|
|
44
|
+
* point just outside the box that a clipped stretch keeps, which must come
|
|
45
|
+
* from its own segment. An untimed point gets timestamp 0, which only the
|
|
46
|
+
* time-free paths of the pipeline ever see.
|
|
47
|
+
*/
|
|
48
|
+
export declare function positionsOf(track: ImportedTrack, clipTo: GeoBounds | null, window?: TimeWindow): TimedPosition[];
|
|
49
|
+
/**
|
|
50
|
+
* At most `maxPoints` evenly chosen points of a track without times, keeping
|
|
51
|
+
* the first and the last. A point that starts a new line passes that on to
|
|
52
|
+
* the next point kept, so dropping it does not join two segments.
|
|
53
|
+
*/
|
|
54
|
+
export declare function decimate(points: TimedPosition[], maxPoints: number): TimedPosition[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
CHANGED
|
@@ -99,6 +99,7 @@ interface Plugin {
|
|
|
99
99
|
enabledByDefault: boolean;
|
|
100
100
|
stop: () => Promise<void>;
|
|
101
101
|
signalKApiRoutes: (r: Router) => Router;
|
|
102
|
+
registerWithRouter: (r: Router) => Router;
|
|
102
103
|
id: string;
|
|
103
104
|
name: string;
|
|
104
105
|
description: string;
|
|
@@ -116,6 +117,8 @@ interface TracksPluginConfig {
|
|
|
116
117
|
resolution?: number;
|
|
117
118
|
/** Days another vessel is kept after its last fix. 0 keeps every vessel. */
|
|
118
119
|
aisRetentionDays?: number;
|
|
120
|
+
/** Days a deleted track waits in the recycle bin before it is purged. */
|
|
121
|
+
recycleBinDays?: number;
|
|
119
122
|
/** Minutes without a fix that start a new track segment. 0 disables. */
|
|
120
123
|
segmentGapMinutes?: number;
|
|
121
124
|
/** Speed above which a position is treated as a glitch. 0 disables. */
|
|
@@ -125,7 +128,7 @@ interface TracksPluginConfig {
|
|
|
125
128
|
}
|
|
126
129
|
export default function ThePlugin(app: App): Plugin;
|
|
127
130
|
export { fromGpx, toGpx } from './gpx.js';
|
|
128
|
-
export type { GpxTrack, GpxTrackIdentity } from './gpx.js';
|
|
131
|
+
export type { GpxExportTrack, GpxPoint, GpxTrack, GpxTrackIdentity } from './gpx.js';
|
|
129
132
|
export { Tracks, TrackAccumulator } from './tracks.js';
|
|
130
133
|
export type { TracksConfig } from './tracks.js';
|
|
131
134
|
export type { TrackStore } from './store.js';
|