@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 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
- 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.
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
- 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
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
- 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.
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<import('./types.js').TimedPosition[]>;
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;
@@ -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
- * 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.
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
- * Refuses a track with neither times nor a vessel. Such a track answers no
151
- * time window, and the server refuses a query across all vessels without one,
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
- /** What names an imported track: its id, name and vessel, over what it was posted with. */
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
- const imported = toImportedTrack(track);
541
- await writableStore().storeImport(imported);
542
- return imported.id;
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 storeImport = store.storeImport.bind(store);
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
- let converted;
2769
- try {
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 storeImport(track);
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: converted.map(({ track }) => `${PLUGIN_ID}:${track.id}`),
2823
+ ids: ids.map((id) => `${PLUGIN_ID}:${id}`),
2784
2824
  skippedPoints: converted.reduce((sum, { skippedPoints }) => sum + skippedPoints, 0)
2785
2825
  });
2786
2826
  })();