@signalk/tracks-plugin 3.2.0 → 3.3.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 +21 -12
- package/dist/asyncTrackStore.d.ts +3 -2
- package/dist/importedTracks.d.ts +2 -4
- package/dist/index.js +78 -38
- package/dist/index.js.map +1 -1
- package/dist/sqliteStore.d.ts +9 -0
- package/dist/store.d.ts +5 -0
- package/dist/trackApi.d.ts +2 -0
- package/dist/trackProvider.d.ts +18 -1
- package/dist/trackStoreProtocol.d.ts +1 -1
- package/dist/trackStoreWorker.js +22 -0
- package/dist/trackStoreWorker.js.map +1 -1
- package/docs/history-and-storage.md +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,25 +49,34 @@ other track, filtered by vessel, time window and area, but as a feature of its o
|
|
|
49
49
|
it names a vessel the plugin also records. It is never merged with a history provider's data,
|
|
50
50
|
never removed by the retention settings, and never returned by the v1 routes, which answer
|
|
51
51
|
where vessels are now. Its own name and any other properties it was posted with come back as
|
|
52
|
-
posted.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
posted. It carries `imported: true`, which a recorded track does not, so a client drawing a
|
|
53
|
+
vessel's recording can leave the import out of it.
|
|
54
|
+
|
|
55
|
+
A timed track of the own vessel is the exception: it is the boat's own passage, logged before
|
|
56
|
+
the plugin was installed or by another plotter, so it is written into the own vessel's
|
|
57
|
+
recording instead of being kept as an import, and the answer is the recording's id,
|
|
58
|
+
`tracks:recorded:<context>`. It replaces what was recorded over the same time: the positions
|
|
59
|
+
this plugin stored there go to the recycle bin first, one entry per segment, and a history
|
|
60
|
+
provider's data over that time is hidden while the entry is there. Restoring the entry brings
|
|
61
|
+
both back. An untimed track of the own vessel stays an import, since it cannot be placed in
|
|
62
|
+
time.
|
|
63
|
+
|
|
64
|
+
A track with neither times (`coordTimes`) nor a vessel (`context`) is kept too. No time
|
|
65
|
+
window or vessel query matches it, but `GET /plugins/tracks/imports` lists it with every other
|
|
66
|
+
import, and `GET /signalk/v2/api/tracks/<id>` returns it.
|
|
57
67
|
|
|
58
68
|
### Importing a GPX file
|
|
59
69
|
|
|
60
70
|
The Tracks webapp imports a GPX file from the **Import** form, and a client can do the same
|
|
61
71
|
with `POST /plugins/tracks/imports`, the file as the request body with `Content-Type:
|
|
62
|
-
application/gpx+xml`. Each track in the file
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
application/gpx+xml`. Each track in the file becomes an imported track as above. Ticking
|
|
73
|
+
**Tracks of my own vessel** (`?self=true`) files them under the own vessel, which writes a
|
|
74
|
+
timed one into the recording; otherwise a track names a vessel only if the file says which, as
|
|
65
75
|
the plugin's own GPX export does.
|
|
66
76
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
lets a plugin say so, and administrator rights on an older one.
|
|
77
|
+
In a timed track, points without a time are skipped, and the answer says how many. The route
|
|
78
|
+
needs write access on a Signal K server that lets a plugin say so, and administrator rights on
|
|
79
|
+
an older one.
|
|
71
80
|
|
|
72
81
|
The webapp lists every imported track, however old and whether or not it has times, next to
|
|
73
82
|
the last 30 days of recorded ones, so a passage imported from years ago can be found there. It
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { TrackStore } from './store.js';
|
|
2
2
|
import { SqliteStoreConfig } from './sqliteStore.js';
|
|
3
|
-
import { Debug, Context, ImportedTrack, ImportFilter, LatLngTuple, TimeWindow, TrackParams } from './types.js';
|
|
3
|
+
import { Debug, Context, ImportedTrack, ImportFilter, LatLngTuple, TimedPosition, TimeWindow, TrackParams } from './types.js';
|
|
4
4
|
import { TrackQuery } from './timeWindow.js';
|
|
5
5
|
interface Limits {
|
|
6
6
|
maxItems?: number;
|
|
@@ -58,7 +58,7 @@ export declare class AsyncTrackStore implements TrackStore {
|
|
|
58
58
|
initialTrack(context: Context, track: LatLngTuple[], timestamps?: number[]): void;
|
|
59
59
|
prune(maxAge: number, keep?: Context): void;
|
|
60
60
|
get(context: Context, window?: TimeWindow): Promise<LatLngTuple[]>;
|
|
61
|
-
getTimed(context: Context, window?: TimeWindow): Promise<
|
|
61
|
+
getTimed(context: Context, window?: TimeWindow): Promise<TimedPosition[]>;
|
|
62
62
|
getAllTracks(query?: TrackQuery): Promise<{
|
|
63
63
|
context: string;
|
|
64
64
|
track: LatLngTuple[];
|
|
@@ -70,6 +70,7 @@ export declare class AsyncTrackStore implements TrackStore {
|
|
|
70
70
|
id?: number;
|
|
71
71
|
known: boolean;
|
|
72
72
|
}>;
|
|
73
|
+
recordTrack(context: Context, segments: TimedPosition[][], deletedAt: number, keepEmpty: boolean): Promise<number[]>;
|
|
73
74
|
binImport(importId: string, span: {
|
|
74
75
|
from?: number;
|
|
75
76
|
to?: number;
|
package/dist/importedTracks.d.ts
CHANGED
|
@@ -14,9 +14,8 @@ export declare const isImportedId: (id: string) => boolean;
|
|
|
14
14
|
* thinning and window code assume time order, and a segment boundary is a gap
|
|
15
15
|
* in recording, so segments are never merged or reordered.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* so it could only ever be found again by an id the client would have to keep.
|
|
17
|
+
* A track with neither times nor a vessel answers no time window and no
|
|
18
|
+
* vessel query; the plugin's import list and its id are what find it again.
|
|
20
19
|
*/
|
|
21
20
|
export declare function toImportedTrack(track: TrackImport, id?: string): ImportedTrack;
|
|
22
21
|
/**
|
|
@@ -27,7 +26,6 @@ export declare function toImportedTrack(track: TrackImport, id?: string): Import
|
|
|
27
26
|
* becomes an untimed import; a file with some keeps only its timed points,
|
|
28
27
|
* because an import is timed throughout or not at all, and its times are what
|
|
29
28
|
* 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
29
|
*/
|
|
32
30
|
export declare function fromGpxTrack(gpx: GpxTrack, context?: string | undefined, id?: string): {
|
|
33
31
|
track: ImportedTrack;
|
package/dist/index.js
CHANGED
|
@@ -117,22 +117,6 @@ function nearestLongitude(lng, origin) {
|
|
|
117
117
|
return lng - 360 * Math.round((lng - origin) / 360);
|
|
118
118
|
}
|
|
119
119
|
//#endregion
|
|
120
|
-
//#region src/trackApi.ts
|
|
121
|
-
/**
|
|
122
|
-
* Thrown by `storeTrack` when this provider will not keep the track, which
|
|
123
|
-
* the server answers with a 400 rather than a 500.
|
|
124
|
-
*
|
|
125
|
-
* The server recognises it by `isTrackRejected` rather than by class, since
|
|
126
|
-
* this is a copy of its own.
|
|
127
|
-
*/
|
|
128
|
-
var TrackRejectedError = class extends Error {
|
|
129
|
-
isTrackRejected = true;
|
|
130
|
-
constructor(message) {
|
|
131
|
-
super(message);
|
|
132
|
-
this.name = "TrackRejectedError";
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
//#endregion
|
|
136
120
|
//#region src/importedTracks.ts
|
|
137
121
|
/**
|
|
138
122
|
* Prefix of every imported track's id. It keeps imported and recorded tracks
|
|
@@ -147,13 +131,11 @@ var isImportedId = (id) => id.startsWith(IMPORTED_PREFIX);
|
|
|
147
131
|
* thinning and window code assume time order, and a segment boundary is a gap
|
|
148
132
|
* in recording, so segments are never merged or reordered.
|
|
149
133
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* so it could only ever be found again by an id the client would have to keep.
|
|
134
|
+
* A track with neither times nor a vessel answers no time window and no
|
|
135
|
+
* vessel query; the plugin's import list and its id are what find it again.
|
|
153
136
|
*/
|
|
154
137
|
function toImportedTrack(track, id = `${IMPORTED_PREFIX}${randomUUID()}`) {
|
|
155
138
|
const { coordTimes, name, context, ...metadata } = track.properties;
|
|
156
|
-
if (coordTimes === void 0 && context === void 0) throw new TrackRejectedError("A track with neither coordTimes nor a context could not be listed again; send one of them");
|
|
157
139
|
const segments = track.geometry.coordinates.map((segment, i) => {
|
|
158
140
|
const points = segment.map(([lon, lat], j) => {
|
|
159
141
|
const time = coordTimes?.[i]?.[j];
|
|
@@ -180,12 +162,10 @@ function toImportedTrack(track, id = `${IMPORTED_PREFIX}${randomUUID()}`) {
|
|
|
180
162
|
* becomes an untimed import; a file with some keeps only its timed points,
|
|
181
163
|
* because an import is timed throughout or not at all, and its times are what
|
|
182
164
|
* let a query find it. `context` names the vessel in place of the file's own.
|
|
183
|
-
* Refused, like any import, when it ends up with neither times nor a vessel.
|
|
184
165
|
*/
|
|
185
166
|
function fromGpxTrack(gpx, context = gpx.context, id = `${IMPORTED_PREFIX}${randomUUID()}`) {
|
|
186
167
|
const all = gpx.segments.flat();
|
|
187
168
|
const timed = all.some(({ timestamp }) => timestamp !== 0);
|
|
188
|
-
if (!timed && context === void 0) throw new TrackRejectedError(`Track '${gpx.name}' has neither times nor a vessel, so it could not be listed again; choose a vessel for it`);
|
|
189
169
|
const segments = gpx.segments.map((points) => points.filter(({ timestamp }) => !timed || timestamp !== 0).map(({ position, timestamp }) => timed ? {
|
|
190
170
|
position,
|
|
191
171
|
timestamp
|
|
@@ -284,6 +264,22 @@ function overlaps(start, width, { from, to }) {
|
|
|
284
264
|
return width > 0 ? start + width > from : start >= from;
|
|
285
265
|
}
|
|
286
266
|
//#endregion
|
|
267
|
+
//#region src/trackApi.ts
|
|
268
|
+
/**
|
|
269
|
+
* Thrown by `storeTrack` when this provider will not keep the track, which
|
|
270
|
+
* the server answers with a 400 rather than a 500.
|
|
271
|
+
*
|
|
272
|
+
* The server recognises it by `isTrackRejected` rather than by class, since
|
|
273
|
+
* this is a copy of its own.
|
|
274
|
+
*/
|
|
275
|
+
var TrackRejectedError = class extends Error {
|
|
276
|
+
isTrackRejected = true;
|
|
277
|
+
constructor(message) {
|
|
278
|
+
super(message);
|
|
279
|
+
this.name = "TrackRejectedError";
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
//#endregion
|
|
287
283
|
//#region src/trackProvider.ts
|
|
288
284
|
/** v2 sends `[west, south, east, north]`; a bounds here is `[lat, lng]` corners. */
|
|
289
285
|
var toGeoBounds = (bbox) => {
|
|
@@ -447,12 +443,16 @@ function createTrackProvider(deps) {
|
|
|
447
443
|
}
|
|
448
444
|
return result;
|
|
449
445
|
};
|
|
450
|
-
/**
|
|
446
|
+
/**
|
|
447
|
+
* What names an imported track: its id, name and vessel, over what it was posted with.
|
|
448
|
+
* `imported` tells it apart from the recording of the same vessel, which it is listed beside.
|
|
449
|
+
*/
|
|
451
450
|
const importIdentity = (track) => {
|
|
452
451
|
const contextName = track.context === void 0 ? void 0 : deps.contextName(track.context);
|
|
453
452
|
return {
|
|
454
453
|
...track.metadata,
|
|
455
454
|
id: track.id,
|
|
455
|
+
imported: true,
|
|
456
456
|
...track.name === void 0 ? {} : { name: track.name },
|
|
457
457
|
...track.context === void 0 ? {} : {
|
|
458
458
|
context: track.context,
|
|
@@ -464,7 +464,7 @@ function createTrackProvider(deps) {
|
|
|
464
464
|
/** The store, when it can keep and delete tracks; an ordinary failure otherwise. */
|
|
465
465
|
const writableStore = () => {
|
|
466
466
|
const store = deps.store();
|
|
467
|
-
if (!store?.storeImport || !store.getImport || !store.binImport || !store.binRecorded) throw new Error("Track storage is not available");
|
|
467
|
+
if (!store?.storeImport || !store.recordTrack || !store.getImport || !store.binImport || !store.binRecorded) throw new Error("Track storage is not available");
|
|
468
468
|
return store;
|
|
469
469
|
};
|
|
470
470
|
/** The recorded feature's identity, which `getTracks` and `getTrack` share. */
|
|
@@ -537,9 +537,12 @@ function createTrackProvider(deps) {
|
|
|
537
537
|
return shapeFeature(positionsOf(track, null), 0, { times: timed }, void 0, importIdentity(track), timed);
|
|
538
538
|
},
|
|
539
539
|
async storeTrack(track) {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
540
|
+
return keepTrack(toImportedTrack(track), {
|
|
541
|
+
store: writableStore(),
|
|
542
|
+
selfContext: deps.selfContext(),
|
|
543
|
+
historyKnows: deps.historyKnows,
|
|
544
|
+
deleted: deps.deleted
|
|
545
|
+
});
|
|
543
546
|
},
|
|
544
547
|
async deleteTrack(id) {
|
|
545
548
|
const recorded = recordedContext(id);
|
|
@@ -669,6 +672,30 @@ function extentEpsilon([west, south, east, north]) {
|
|
|
669
672
|
const width = (east >= west ? east - west : east + 360 - west) * M_PER_DEG * Math.cos(midLat * Math.PI / 180);
|
|
670
673
|
return Math.max(Math.hypot(width, height) / AUTO_EPSILON_DIVISOR, MIN_AUTO_EPSILON);
|
|
671
674
|
}
|
|
675
|
+
/**
|
|
676
|
+
* Keep a client's track, resolving to the id that addresses it.
|
|
677
|
+
*
|
|
678
|
+
* A timed track of the own vessel is the vessel's own passage, from before
|
|
679
|
+
* the plugin recorded it or from another plotter, so it is written into the
|
|
680
|
+
* recording in place of what was recorded over the same time and answers
|
|
681
|
+
* as `recorded:<context>`. Everything else is kept as an import: another
|
|
682
|
+
* vessel's track is not this boat's recording, and an untimed one cannot be
|
|
683
|
+
* placed in it.
|
|
684
|
+
*/
|
|
685
|
+
async function keepTrack(track, deps) {
|
|
686
|
+
const context = track.context === void 0 ? void 0 : resolveSelf(deps.selfContext)(track.context);
|
|
687
|
+
if (context === deps.selfContext && isTimed(track)) {
|
|
688
|
+
const segments = track.segments.map((points) => points.map(({ position, timestamp }) => ({
|
|
689
|
+
position,
|
|
690
|
+
timestamp
|
|
691
|
+
})));
|
|
692
|
+
await deps.store.recordTrack(context, segments, Date.now(), await deps.historyKnows(context));
|
|
693
|
+
deps.deleted();
|
|
694
|
+
return recordedId(context);
|
|
695
|
+
}
|
|
696
|
+
await deps.store.storeImport(track);
|
|
697
|
+
return track.id;
|
|
698
|
+
}
|
|
672
699
|
/** v2 accepts the `self` alias; the store keys on the qualified context. */
|
|
673
700
|
var resolveSelf = (selfContext) => (context) => context === "self" || context === "vessels.self" ? selfContext : context;
|
|
674
701
|
//#endregion
|
|
@@ -1002,6 +1029,14 @@ var AsyncTrackStore = class {
|
|
|
1002
1029
|
keepEmpty
|
|
1003
1030
|
], "acknowledged");
|
|
1004
1031
|
}
|
|
1032
|
+
recordTrack(context, segments, deletedAt, keepEmpty) {
|
|
1033
|
+
return this.enqueue("recordTrack", [
|
|
1034
|
+
context,
|
|
1035
|
+
segments,
|
|
1036
|
+
deletedAt,
|
|
1037
|
+
keepEmpty
|
|
1038
|
+
], "acknowledged");
|
|
1039
|
+
}
|
|
1005
1040
|
binImport(importId, span, deletedAt) {
|
|
1006
1041
|
return this.enqueue("binImport", [
|
|
1007
1042
|
importId,
|
|
@@ -2744,11 +2779,14 @@ function ThePlugin(app) {
|
|
|
2744
2779
|
});
|
|
2745
2780
|
(router.access?.("readwrite") ?? router).post("/imports", (req, res) => {
|
|
2746
2781
|
const store = tracks;
|
|
2747
|
-
if (!store?.storeImport) {
|
|
2782
|
+
if (!store?.storeImport || !store.recordTrack) {
|
|
2748
2783
|
notAvailable(res);
|
|
2749
2784
|
return;
|
|
2750
2785
|
}
|
|
2751
|
-
const
|
|
2786
|
+
const writableStore = {
|
|
2787
|
+
storeImport: store.storeImport.bind(store),
|
|
2788
|
+
recordTrack: store.recordTrack.bind(store)
|
|
2789
|
+
};
|
|
2752
2790
|
const context = req.query.self === "true" ? app.selfContext : void 0;
|
|
2753
2791
|
(async () => {
|
|
2754
2792
|
let text;
|
|
@@ -2765,22 +2803,24 @@ function ThePlugin(app) {
|
|
|
2765
2803
|
res.status(400).json({ message: "No track found: the file is not GPX, or has no track with points" });
|
|
2766
2804
|
return;
|
|
2767
2805
|
}
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
converted = read.map((gpx) => fromGpxTrack(gpx, context ?? gpx.context));
|
|
2771
|
-
} catch (err) {
|
|
2772
|
-
res.status(400).json({ message: err instanceof TrackRejectedError ? err.message : String(err) });
|
|
2773
|
-
return;
|
|
2774
|
-
}
|
|
2806
|
+
const converted = read.map((gpx) => fromGpxTrack(gpx, context ?? gpx.context));
|
|
2807
|
+
const ids = [];
|
|
2775
2808
|
try {
|
|
2776
|
-
for (const { track } of converted) await
|
|
2809
|
+
for (const { track } of converted) ids.push(await keepTrack(track, {
|
|
2810
|
+
store: writableStore,
|
|
2811
|
+
selfContext: app.selfContext,
|
|
2812
|
+
historyKnows: (vessel) => historyKnowsContext(app, vessel, app.debug, knownContexts),
|
|
2813
|
+
deleted: () => {
|
|
2814
|
+
deletedSpans = void 0;
|
|
2815
|
+
}
|
|
2816
|
+
}));
|
|
2777
2817
|
} catch (err) {
|
|
2778
2818
|
app.error(`GPX import: ${errorDetail(err)}`);
|
|
2779
2819
|
res.status(500).json({ message: "Track storage failed" });
|
|
2780
2820
|
return;
|
|
2781
2821
|
}
|
|
2782
2822
|
res.status(201).json({
|
|
2783
|
-
ids:
|
|
2823
|
+
ids: ids.map((id) => `${PLUGIN_ID}:${id}`),
|
|
2784
2824
|
skippedPoints: converted.reduce((sum, { skippedPoints }) => sum + skippedPoints, 0)
|
|
2785
2825
|
});
|
|
2786
2826
|
})();
|