@signalk/tracks-plugin 3.1.1 → 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/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import { C as uncoveredBetween, S as thinToBudget, _ as validateParameters, a as contextName, b as segment, c as createMatcher, d as resolveContext, f as rfc8187, g as unwrapString, h as trackLabel, i as clipToBounds, l as gpxFilename, m as toIsoTimes, n as SelfPositionUnavailableError, o as createDistanceTo, r as asciiFilename, s as createInBounds, t as QueryParameterError, u as historyRowPosition, v as TimeWindowError, x as thin, y as parseTrackQuery } from "./utils-CGIGp4Xf.js";
1
+ import { C as thinToBudget, S as thin, _ as unwrapString, a as contextName, b as parseTrackQuery, c as createMatcher, d as longitudeSpan, f as resolveContext, g as trackLabel, h as toIsoTimes, i as clipToBounds, l as gpxFilename, n as SelfPositionUnavailableError, o as createDistanceTo, p as rfc8187, r as asciiFilename, s as createInBounds, t as QueryParameterError, u as historyRowPosition, v as validateParameters, w as uncoveredBetween, x as segment, y as TimeWindowError } from "./utils-C0VkiidM.js";
2
2
  import { Temporal } from "@js-temporal/polyfill";
3
3
  import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
4
5
  import { Worker } from "node:worker_threads";
5
6
  import { DOMParser } from "@xmldom/xmldom";
6
7
  import { BehaviorSubject, ReplaySubject, Subject, combineLatest, connectable, firstValueFrom } from "rxjs";
@@ -116,6 +117,169 @@ function nearestLongitude(lng, origin) {
116
117
  return lng - 360 * Math.round((lng - origin) / 360);
117
118
  }
118
119
  //#endregion
120
+ //#region src/importedTracks.ts
121
+ /**
122
+ * Prefix of every imported track's id. It keeps imported and recorded tracks
123
+ * in separate id spaces, so no import can ever address a recorded track.
124
+ */
125
+ var IMPORTED_PREFIX = "imported:";
126
+ var isImportedId = (id) => id.startsWith(IMPORTED_PREFIX);
127
+ /**
128
+ * The track the server handed over, as this plugin stores it.
129
+ *
130
+ * Times are sorted within each segment, the way `fromGpx` reads a file: the
131
+ * thinning and window code assume time order, and a segment boundary is a gap
132
+ * in recording, so segments are never merged or reordered.
133
+ *
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.
136
+ */
137
+ function toImportedTrack(track, id = `${IMPORTED_PREFIX}${randomUUID()}`) {
138
+ const { coordTimes, name, context, ...metadata } = track.properties;
139
+ const segments = track.geometry.coordinates.map((segment, i) => {
140
+ const points = segment.map(([lon, lat], j) => {
141
+ const time = coordTimes?.[i]?.[j];
142
+ return time === void 0 ? { position: [lat, lon] } : {
143
+ position: [lat, lon],
144
+ timestamp: Date.parse(time)
145
+ };
146
+ });
147
+ return coordTimes === void 0 ? points : points.sort((a, b) => a.timestamp - b.timestamp);
148
+ });
149
+ return {
150
+ id,
151
+ ...context === void 0 ? {} : { context },
152
+ ...name === void 0 ? {} : { name },
153
+ metadata,
154
+ segments
155
+ };
156
+ }
157
+ /**
158
+ * A track read from a GPX file, as this plugin stores it, with the number of
159
+ * points left out.
160
+ *
161
+ * `fromGpx` dates a point with no `<time>` to 0. A file with no times at all
162
+ * becomes an untimed import; a file with some keeps only its timed points,
163
+ * because an import is timed throughout or not at all, and its times are what
164
+ * let a query find it. `context` names the vessel in place of the file's own.
165
+ */
166
+ function fromGpxTrack(gpx, context = gpx.context, id = `${IMPORTED_PREFIX}${randomUUID()}`) {
167
+ const all = gpx.segments.flat();
168
+ const timed = all.some(({ timestamp }) => timestamp !== 0);
169
+ const segments = gpx.segments.map((points) => points.filter(({ timestamp }) => !timed || timestamp !== 0).map(({ position, timestamp }) => timed ? {
170
+ position,
171
+ timestamp
172
+ } : { position })).filter((points) => points.length > 0);
173
+ const kept = segments.reduce((sum, points) => sum + points.length, 0);
174
+ return {
175
+ track: {
176
+ id,
177
+ ...context === void 0 ? {} : { context },
178
+ name: gpx.name,
179
+ metadata: {},
180
+ segments
181
+ },
182
+ skippedPoints: all.length - kept
183
+ };
184
+ }
185
+ /** Whether the track's points carry times; an import is timed throughout or not at all. */
186
+ var isTimed = (track) => track.segments[0]?.[0]?.timestamp !== void 0;
187
+ /**
188
+ * The track as one list of points for the shared pipeline, each posted segment
189
+ * after the first starting a new line.
190
+ *
191
+ * Narrowed to the window and clipped per segment, so a line never joins two
192
+ * segments: not where the window empties the start of one, nor through the
193
+ * point just outside the box that a clipped stretch keeps, which must come
194
+ * from its own segment. An untimed point gets timestamp 0, which only the
195
+ * time-free paths of the pipeline ever see.
196
+ */
197
+ function positionsOf(track, clipTo, window) {
198
+ const result = [];
199
+ for (const segment of track.segments) {
200
+ let points = segment.map(({ position, timestamp }) => ({
201
+ position,
202
+ timestamp: timestamp ?? 0
203
+ }));
204
+ if (window) points = points.filter(({ timestamp }) => window.inclusiveEnd ? timestamp >= window.from && timestamp <= window.to : timestamp >= window.from && timestamp < window.to);
205
+ const kept = clipTo ? clipToBounds(points, clipTo) : points;
206
+ for (const [i, point] of kept.entries()) result.push(i === 0 && result.length > 0 ? {
207
+ ...point,
208
+ breakBefore: true
209
+ } : point);
210
+ }
211
+ return result;
212
+ }
213
+ /**
214
+ * At most `maxPoints` evenly chosen points of a track without times, keeping
215
+ * the first and the last. A point that starts a new line passes that on to
216
+ * the next point kept, so dropping it does not join two segments.
217
+ */
218
+ function decimate(points, maxPoints) {
219
+ if (points.length <= maxPoints) return points;
220
+ if (maxPoints < 2) return points.slice(0, Math.max(0, maxPoints));
221
+ const step = (points.length - 1) / (maxPoints - 1);
222
+ const result = [];
223
+ let previous = -1;
224
+ for (let k = 0; k < maxPoints; k++) {
225
+ const index = Math.round(k * step);
226
+ const breaks = points.slice(previous + 1, index + 1).some(({ breakBefore }) => breakBefore);
227
+ const point = points[index];
228
+ result.push(breaks && result.length > 0 ? {
229
+ ...point,
230
+ breakBefore: true
231
+ } : point);
232
+ previous = index;
233
+ }
234
+ return result;
235
+ }
236
+ //#endregion
237
+ //#region src/recycleBin.ts
238
+ /**
239
+ * Prefix of a recorded track's id. The rest is the vessel's context: a vessel
240
+ * has one recording, so its context is all an id needs, and a provider may
241
+ * derive the id from it, as the server's Track API foresees.
242
+ */
243
+ var RECORDED_PREFIX = "recorded:";
244
+ var recordedId = (context) => `${RECORDED_PREFIX}${context}`;
245
+ /** The context a recorded track's id names, or undefined for any other id. */
246
+ var recordedContext = (id) => id.startsWith("recorded:") && id.length > 9 ? id.slice(9) : void 0;
247
+ /**
248
+ * The points outside every deleted span.
249
+ *
250
+ * Applied to what a history provider returns, which this plugin cannot delete
251
+ * from: without it the next query would fill a deleted span back in. A
252
+ * history point stands for a bucket starting at its timestamp, and the fix it
253
+ * carries can lie anywhere in that bucket, so a bucket that overlaps a span at
254
+ * all is dropped; the store's own points answer for the rest of it.
255
+ */
256
+ function outsideSpans(points, spans) {
257
+ if (spans.length === 0) return points;
258
+ return points.filter(({ timestamp, span = 0 }) => !spans.some((deleted) => overlaps(timestamp, span, deleted)));
259
+ }
260
+ /** Whether `[start, start + width)`, or the instant `start` when `width` is 0, meets `[from, to]`. */
261
+ function overlaps(start, width, { from, to }) {
262
+ if (start > to) return false;
263
+ if (from === void 0) return true;
264
+ return width > 0 ? start + width > from : start >= from;
265
+ }
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
119
283
  //#region src/trackProvider.ts
120
284
  /** v2 sends `[west, south, east, north]`; a bounds here is `[lat, lng]` corners. */
121
285
  var toGeoBounds = (bbox) => {
@@ -202,36 +366,6 @@ var boundsOf = (points) => {
202
366
  north
203
367
  ];
204
368
  };
205
- /**
206
- * The narrower of the two longitude intervals covering these points.
207
- *
208
- * A plain min/max describes a track spanning the antimeridian as almost the
209
- * whole globe: two fixes at 179 and -179 are two degrees apart, but min/max
210
- * reports 358. RFC 7946 says a box crossing the antimeridian is written with
211
- * west greater than east, so the wrap-around interval is expressible — pick it
212
- * whenever it is the shorter of the two.
213
- *
214
- * The widest gap between adjacent longitudes is the part of the globe the track
215
- * does not cover, so the complement of that gap is the tightest interval that
216
- * does.
217
- */
218
- var longitudeSpan = (longitudes) => {
219
- const sorted = [...longitudes].sort((a, b) => a - b);
220
- const min = sorted[0];
221
- const max = sorted[sorted.length - 1];
222
- let widestGap = 360 - (max - min);
223
- let west = min;
224
- let east = max;
225
- for (let i = 1; i < sorted.length; i++) {
226
- const gap = sorted[i] - sorted[i - 1];
227
- if (gap > widestGap) {
228
- widestGap = gap;
229
- west = sorted[i];
230
- east = sorted[i - 1];
231
- }
232
- }
233
- return [west, east];
234
- };
235
369
  function createTrackProvider(deps) {
236
370
  const matching = async (query) => {
237
371
  const store = deps.store();
@@ -274,42 +408,104 @@ function createTrackProvider(deps) {
274
408
  resolution
275
409
  };
276
410
  };
411
+ /**
412
+ * Imported tracks the query matches, each with the points it returns.
413
+ *
414
+ * The same filters as a recorded track, applied to the import's own points:
415
+ * a context names the vessel it was imported for, a window needs times, a
416
+ * box needs a point inside it and clips like a recorded track. Never
417
+ * reconciled with history, which knows nothing of an import.
418
+ */
419
+ const matchingImports = async (query) => {
420
+ const store = deps.store();
421
+ if (!store?.findImports) return [];
422
+ const window = toTimeWindow(query);
423
+ const resolution = query.resolution ? totalMilliseconds(query.resolution) : void 0;
424
+ const bounds = toGeoBounds(query.bbox);
425
+ const clipTo = query.clip === true ? bounds : null;
426
+ const inBounds = bounds ? createInBounds(bounds) : void 0;
427
+ const imports = await store.findImports({
428
+ ...query.contexts?.length ? { contexts: [...new Set(query.contexts.map(resolveSelf(deps.selfContext())))] } : {},
429
+ ...window ? { window } : {},
430
+ ...bounds ? { bbox: bounds } : {}
431
+ });
432
+ const result = [];
433
+ for (const track of imports) {
434
+ const timed = isTimed(track);
435
+ if (inBounds && !positionsOf(track, null, window).some(({ position }) => inBounds(position))) continue;
436
+ let points = positionsOf(track, clipTo, window);
437
+ if (timed) points = thin(points, resolution);
438
+ if (points.length > 0) result.push({
439
+ track,
440
+ points,
441
+ timed
442
+ });
443
+ }
444
+ return result;
445
+ };
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
+ */
450
+ const importIdentity = (track) => {
451
+ const contextName = track.context === void 0 ? void 0 : deps.contextName(track.context);
452
+ return {
453
+ ...track.metadata,
454
+ id: track.id,
455
+ imported: true,
456
+ ...track.name === void 0 ? {} : { name: track.name },
457
+ ...track.context === void 0 ? {} : {
458
+ context: track.context,
459
+ isSelf: track.context === deps.selfContext(),
460
+ ...contextName === void 0 ? {} : { contextName }
461
+ }
462
+ };
463
+ };
464
+ /** The store, when it can keep and delete tracks; an ordinary failure otherwise. */
465
+ const writableStore = () => {
466
+ const store = deps.store();
467
+ if (!store?.storeImport || !store.recordTrack || !store.getImport || !store.binImport || !store.binRecorded) throw new Error("Track storage is not available");
468
+ return store;
469
+ };
470
+ /** The recorded feature's identity, which `getTracks` and `getTrack` share. */
471
+ const recordedIdentity = (context) => {
472
+ const name = deps.contextName(context);
473
+ return {
474
+ id: recordedId(context),
475
+ context,
476
+ isSelf: context === deps.selfContext(),
477
+ ...name === void 0 ? {} : { contextName: name }
478
+ };
479
+ };
480
+ /**
481
+ * Move a vessel's recording within the span to the recycle bin, resolving
482
+ * false for a vessel neither the store nor history knows.
483
+ *
484
+ * `to` is the moment of the delete, never the end of time: recording goes
485
+ * on, and what it records afterwards is a new track, not a deleted one.
486
+ */
487
+ const deleteRecorded = async (context, from, to) => {
488
+ const now = Date.now();
489
+ const end = Math.min(to ?? now, now);
490
+ const historyKnown = await deps.historyKnows(context);
491
+ const keepEmpty = historyKnown && (from === void 0 || from <= end);
492
+ const { known } = await writableStore().binRecorded(context, from, end, now, keepEmpty);
493
+ deps.deleted();
494
+ return known || historyKnown;
495
+ };
277
496
  return {
278
497
  async getTracks(query) {
279
498
  const { tracks: matched, resolution: requested } = await matching(query);
280
499
  const gap = deps.segmentGap();
281
- const selfContext = deps.selfContext();
282
500
  const features = [];
283
501
  for (const [context, all] of matched) {
284
- const { points, resolution: appliedMs } = query.maxPoints === void 0 ? {
285
- points: all,
286
- resolution: requested
287
- } : thinToBudget(all, query.maxPoints, requested);
288
- if (points.length === 0) continue;
289
- const chosenEpsilon = chooseEpsilon(points, query);
290
- const segments = segment(points, gap).map((s) => chosenEpsilon === void 0 ? s : simplify(s, chosenEpsilon));
291
- const shaped = segments.flat();
292
- const bbox = boundsOf(shaped);
293
- const name = deps.contextName(context);
294
- features.push({
295
- type: "Feature",
296
- geometry: query.geometry === false ? null : {
297
- type: "MultiLineString",
298
- coordinates: segments.map((s) => s.map(({ position }) => toLngLat$1(position)))
299
- },
300
- properties: {
301
- context,
302
- isSelf: context === selfContext,
303
- ...name === void 0 ? {} : { contextName: name },
304
- from: new Date(shaped[0].timestamp).toISOString(),
305
- to: new Date(shaped[shaped.length - 1].timestamp).toISOString(),
306
- ...bbox ? { bbox } : {},
307
- pointCount: shaped.length,
308
- ...appliedMs === void 0 ? {} : { resolution: msToDuration(appliedMs).toString() },
309
- ...chosenEpsilon === void 0 ? {} : { epsilon: chosenEpsilon },
310
- ...query.times ? { coordTimes: segments.map(toIsoTimes) } : {}
311
- }
312
- });
502
+ const feature = shapeFeature(all, gap, query, requested, recordedIdentity(context));
503
+ if (feature) features.push(feature);
504
+ }
505
+ const requestedForImports = query.resolution ? totalMilliseconds(query.resolution) : void 0;
506
+ for (const { track, points, timed } of await matchingImports(query)) {
507
+ const feature = shapeFeature(points, 0, query, timed ? requestedForImports : void 0, importIdentity(track), timed);
508
+ if (feature) features.push(feature);
313
509
  }
314
510
  return {
315
511
  type: "FeatureCollection",
@@ -317,7 +513,98 @@ function createTrackProvider(deps) {
317
513
  };
318
514
  },
319
515
  async getTrackContexts(query) {
320
- return [...(await matching(query)).tracks.keys()];
516
+ const [recorded, imported] = await Promise.all([matching(query), matchingImports(query)]);
517
+ const contexts = new Set(recorded.tracks.keys());
518
+ for (const { track } of imported) if (track.context !== void 0) contexts.add(track.context);
519
+ return [...contexts];
520
+ },
521
+ async getTrack(id) {
522
+ const recorded = recordedContext(id);
523
+ if (recorded !== void 0) {
524
+ const context = resolveSelf(deps.selfContext())(recorded);
525
+ const query = {
526
+ contexts: [context],
527
+ times: true
528
+ };
529
+ const { tracks } = await matching(query);
530
+ const points = tracks.get(context);
531
+ return points && shapeFeature(points, deps.segmentGap(), query, void 0, recordedIdentity(context));
532
+ }
533
+ if (!isImportedId(id)) return;
534
+ const track = await writableStore().getImport(id);
535
+ if (!track) return;
536
+ const timed = isTimed(track);
537
+ return shapeFeature(positionsOf(track, null), 0, { times: timed }, void 0, importIdentity(track), timed);
538
+ },
539
+ async storeTrack(track) {
540
+ return keepTrack(toImportedTrack(track), {
541
+ store: writableStore(),
542
+ selfContext: deps.selfContext(),
543
+ historyKnows: deps.historyKnows,
544
+ deleted: deps.deleted
545
+ });
546
+ },
547
+ async deleteTrack(id) {
548
+ const recorded = recordedContext(id);
549
+ if (recorded !== void 0) return deleteRecorded(resolveSelf(deps.selfContext())(recorded), void 0, void 0);
550
+ if (!isImportedId(id)) return false;
551
+ return await writableStore().binImport(id, void 0, Date.now()) !== void 0;
552
+ },
553
+ async deleteTrackSpan(id, span) {
554
+ const from = span.from?.epochMilliseconds;
555
+ const to = span.to?.epochMilliseconds;
556
+ const recorded = recordedContext(id);
557
+ if (recorded !== void 0) return deleteRecorded(resolveSelf(deps.selfContext())(recorded), from, to);
558
+ if (!isImportedId(id)) return false;
559
+ const store = writableStore();
560
+ const track = await store.getImport(id);
561
+ if (!track) return false;
562
+ if (!isTimed(track)) throw new TrackRejectedError("This track has no times, so no part of it can be addressed by time");
563
+ await store.binImport(id, {
564
+ ...from === void 0 ? {} : { from },
565
+ ...to === void 0 ? {} : { to }
566
+ }, Date.now());
567
+ return true;
568
+ }
569
+ };
570
+ }
571
+ /**
572
+ * One track as a Feature, or undefined when the budget leaves nothing to draw.
573
+ *
574
+ * `identity` is whatever names the track — context, vessel name — and comes
575
+ * first, so the derived properties after it always describe the geometry
576
+ * actually returned.
577
+ */
578
+ function shapeFeature(all, gap, query, requested, identity, timed = true) {
579
+ const { points, resolution: appliedMs } = query.maxPoints === void 0 ? {
580
+ points: all,
581
+ resolution: requested
582
+ } : timed ? thinToBudget(all, query.maxPoints, requested) : {
583
+ points: decimate(all, query.maxPoints),
584
+ resolution: void 0
585
+ };
586
+ if (points.length === 0) return;
587
+ const chosenEpsilon = chooseEpsilon(points, query);
588
+ const segments = segment(points, gap).map((s) => chosenEpsilon === void 0 ? s : simplify(s, chosenEpsilon));
589
+ const shaped = segments.flat();
590
+ const bbox = boundsOf(shaped);
591
+ return {
592
+ type: "Feature",
593
+ geometry: query.geometry === false ? null : {
594
+ type: "MultiLineString",
595
+ coordinates: segments.map((s) => s.map(({ position }) => toLngLat$1(position)))
596
+ },
597
+ properties: {
598
+ ...identity,
599
+ ...timed ? {
600
+ from: new Date(shaped[0].timestamp).toISOString(),
601
+ to: new Date(shaped[shaped.length - 1].timestamp).toISOString()
602
+ } : {},
603
+ ...bbox ? { bbox } : {},
604
+ pointCount: shaped.length,
605
+ ...appliedMs === void 0 ? {} : { resolution: msToDuration(appliedMs).toString() },
606
+ ...chosenEpsilon === void 0 ? {} : { epsilon: chosenEpsilon },
607
+ ...query.times && timed ? { coordTimes: segments.map(toIsoTimes) } : {}
321
608
  }
322
609
  };
323
610
  }
@@ -385,6 +672,30 @@ function extentEpsilon([west, south, east, north]) {
385
672
  const width = (east >= west ? east - west : east + 360 - west) * M_PER_DEG * Math.cos(midLat * Math.PI / 180);
386
673
  return Math.max(Math.hypot(width, height) / AUTO_EPSILON_DIVISOR, MIN_AUTO_EPSILON);
387
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
+ }
388
699
  /** v2 accepts the `self` alias; the store keys on the qualified context. */
389
700
  var resolveSelf = (selfContext) => (context) => context === "self" || context === "vessels.self" ? selfContext : context;
390
701
  //#endregion
@@ -483,8 +794,10 @@ var AsyncTrackStore = class {
483
794
  this.queue = [];
484
795
  this.pendingBytes = 0;
485
796
  }
486
- enqueue(method, args, write = false) {
797
+ enqueue(method, args, kind = "read") {
487
798
  this.resumeIfDrained();
799
+ if (kind === "acknowledged") return this.acknowledged(method, args);
800
+ const write = kind === "write";
488
801
  if (this.failed || this.closing && method !== "close" || write && this.statusError) {
489
802
  if (write && this.overloaded) this.dropped++;
490
803
  const error = new Error(this.statusError ?? "Track store is closing");
@@ -529,6 +842,37 @@ var AsyncTrackStore = class {
529
842
  this.schedule();
530
843
  return result;
531
844
  }
845
+ /**
846
+ * A write whose caller waits for the commit: an imported track, or its
847
+ * deletion. Admitted outside the recording budget, which is sized for
848
+ * positions; an import of a few megabytes would otherwise pause recording,
849
+ * and then be dropped like a position. Refused once writes have failed,
850
+ * since that state lasts until a restart.
851
+ */
852
+ acknowledged(method, args) {
853
+ if (this.failed || this.closing || this.writeFailed) return Promise.reject(new Error(this.statusError ?? "Track store cannot write"));
854
+ let copy;
855
+ try {
856
+ copy = structuredClone(args);
857
+ } catch (error) {
858
+ return Promise.reject(error instanceof Error ? error : new Error(String(error)));
859
+ }
860
+ const item = {
861
+ operation: {
862
+ id: ++this.sequence,
863
+ method,
864
+ args: copy
865
+ },
866
+ bytes: 0
867
+ };
868
+ const result = new Promise((resolve, reject) => Object.assign(item, {
869
+ resolve,
870
+ reject
871
+ }));
872
+ this.queue.push(item);
873
+ this.schedule();
874
+ return result;
875
+ }
532
876
  schedule() {
533
877
  if (this.scheduled || !this.initialized || this.active.length || this.failed) return;
534
878
  this.scheduled = true;
@@ -614,7 +958,7 @@ var AsyncTrackStore = class {
614
958
  context,
615
959
  position,
616
960
  timestamp
617
- ], true);
961
+ ], "write");
618
962
  if (this.sequence !== sequence && this.resolution > 0) this.admitted.set(context, timestamp);
619
963
  }
620
964
  recordName(context, name, timestamp = Date.now()) {
@@ -622,7 +966,7 @@ var AsyncTrackStore = class {
622
966
  context,
623
967
  name,
624
968
  timestamp
625
- ], true);
969
+ ], "write");
626
970
  }
627
971
  nameFor(context) {
628
972
  return this.names.get(context);
@@ -633,7 +977,7 @@ var AsyncTrackStore = class {
633
977
  context,
634
978
  track,
635
979
  timestamps
636
- ], true);
980
+ ], "write");
637
981
  if (this.sequence !== sequence) this.admitted.delete(context);
638
982
  }
639
983
  prune(maxAge, keep) {
@@ -643,7 +987,7 @@ var AsyncTrackStore = class {
643
987
  maxAge,
644
988
  keep,
645
989
  now
646
- ], true);
990
+ ], "write");
647
991
  if (this.sequence !== sequence) {
648
992
  for (const [context, timestamp] of this.admitted) if (context !== keep && timestamp < now - maxAge) this.admitted.delete(context);
649
993
  }
@@ -673,6 +1017,51 @@ var AsyncTrackStore = class {
673
1017
  query
674
1018
  ]);
675
1019
  }
1020
+ storeImport(track) {
1021
+ return this.enqueue("storeImport", [track], "acknowledged");
1022
+ }
1023
+ binRecorded(context, from, to, deletedAt, keepEmpty) {
1024
+ return this.enqueue("binRecorded", [
1025
+ context,
1026
+ from,
1027
+ to,
1028
+ deletedAt,
1029
+ keepEmpty
1030
+ ], "acknowledged");
1031
+ }
1032
+ recordTrack(context, segments, deletedAt, keepEmpty) {
1033
+ return this.enqueue("recordTrack", [
1034
+ context,
1035
+ segments,
1036
+ deletedAt,
1037
+ keepEmpty
1038
+ ], "acknowledged");
1039
+ }
1040
+ binImport(importId, span, deletedAt) {
1041
+ return this.enqueue("binImport", [
1042
+ importId,
1043
+ span,
1044
+ deletedAt
1045
+ ], "acknowledged");
1046
+ }
1047
+ restoreFromBin(id) {
1048
+ return this.enqueue("restoreFromBin", [id], "acknowledged");
1049
+ }
1050
+ purgeFromBin(id) {
1051
+ return this.enqueue("purgeFromBin", [id], "acknowledged");
1052
+ }
1053
+ binEntries() {
1054
+ return this.enqueue("binEntries", []);
1055
+ }
1056
+ deletedSpans() {
1057
+ return this.enqueue("deletedSpans", []);
1058
+ }
1059
+ getImport(id) {
1060
+ return this.enqueue("getImport", [id]);
1061
+ }
1062
+ findImports(filter) {
1063
+ return this.enqueue("findImports", [filter]);
1064
+ }
676
1065
  close() {
677
1066
  if (!this.closePromise) {
678
1067
  this.closing = true;
@@ -1143,7 +1532,12 @@ function toGpx(tracks, now = /* @__PURE__ */ new Date()) {
1143
1532
  for (const points of segments) {
1144
1533
  lines.push(" <trkseg>");
1145
1534
  for (const { position, timestamp } of points) {
1146
- lines.push(` <trkpt lat="${coordinate(position[LAT])}" lon="${coordinate(position[LNG])}">`);
1535
+ const at = `lat="${coordinate(position[LAT])}" lon="${coordinate(position[LNG])}"`;
1536
+ if (timestamp === void 0) {
1537
+ lines.push(` <trkpt ${at}/>`);
1538
+ continue;
1539
+ }
1540
+ lines.push(` <trkpt ${at}>`);
1147
1541
  lines.push(` <time>${iso(timestamp)}</time>`);
1148
1542
  lines.push(" </trkpt>");
1149
1543
  }
@@ -1376,6 +1770,53 @@ function inRange(latitude, longitude) {
1376
1770
  return Number.isFinite(latitude) && Number.isFinite(longitude) && latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180;
1377
1771
  }
1378
1772
  //#endregion
1773
+ //#region src/upload.ts
1774
+ /** The largest GPX file the upload route reads, so one request cannot exhaust memory. */
1775
+ var MAX_UPLOAD_BYTES = 26214400;
1776
+ var UploadTooLargeError = class extends Error {
1777
+ constructor(limit) {
1778
+ super(`The file is larger than ${Math.round(limit / 1048576)} MB`);
1779
+ }
1780
+ };
1781
+ /** A body the server already parsed as something other than a file. */
1782
+ var UploadTypeError = class extends Error {
1783
+ constructor() {
1784
+ super("Send the file as the request body, with Content-Type application/gpx+xml");
1785
+ }
1786
+ };
1787
+ /** XML allows a byte-order mark, and the GPX parser does not skip one. */
1788
+ var withoutBom = (text) => text.startsWith("") ? text.slice(1) : text;
1789
+ /**
1790
+ * A request's body as UTF-8 text, refused past `limit` bytes.
1791
+ *
1792
+ * Read here rather than by a body parser because the server parses only JSON
1793
+ * and form bodies, and a GPX file arrives as XML. A body some other middleware
1794
+ * has already read as text is taken as it is; one it read as anything else is
1795
+ * refused, since the stream it came from is spent.
1796
+ */
1797
+ function readText(req, limit = MAX_UPLOAD_BYTES) {
1798
+ const parsed = req.body;
1799
+ if (typeof parsed === "string") return Buffer.byteLength(parsed) > limit ? Promise.reject(new UploadTooLargeError(limit)) : Promise.resolve(withoutBom(parsed));
1800
+ if (req.readableEnded) return Promise.reject(new UploadTypeError());
1801
+ if (Number(req.headers["content-length"]) > limit) return Promise.reject(new UploadTooLargeError(limit));
1802
+ return new Promise((resolve, reject) => {
1803
+ const chunks = [];
1804
+ let size = 0;
1805
+ req.on("data", (chunk) => {
1806
+ size += chunk.length;
1807
+ if (size > limit) {
1808
+ reject(new UploadTooLargeError(limit));
1809
+ req.removeAllListeners("data");
1810
+ req.resume();
1811
+ return;
1812
+ }
1813
+ chunks.push(chunk);
1814
+ });
1815
+ req.on("end", () => resolve(withoutBom(Buffer.concat(chunks).toString("utf8"))));
1816
+ req.on("error", reject);
1817
+ });
1818
+ }
1819
+ //#endregion
1379
1820
  //#region src/tracks.ts
1380
1821
  /**
1381
1822
  * The in-memory store: a bounded sliding window of positions per context, held
@@ -1539,9 +1980,13 @@ var fetchTrack = (context) => {
1539
1980
  };
1540
1981
  //#endregion
1541
1982
  //#region src/index.ts
1983
+ /** The plugin's id, which the server also uses as its Track API provider id. */
1984
+ var PLUGIN_ID = "tracks";
1542
1985
  var toLngLat = ([lat, lng]) => [lng, lat];
1543
1986
  var DEFAULT_RESOLUTION = 6e4;
1544
1987
  var DEFAULT_AIS_RETENTION_DAYS = 30;
1988
+ var DEFAULT_RECYCLE_BIN_DAYS = 180;
1989
+ var DAY_MS = 864e5;
1545
1990
  var PRUNE_INTERVAL_MS = 36e5;
1546
1991
  var DEFAULT_SEGMENT_GAP_MINUTES = 0;
1547
1992
  var SOURCE_STATUS_INTERVAL_MS = 3e4;
@@ -1893,6 +2338,45 @@ function ThePlugin(app) {
1893
2338
  */
1894
2339
  let storageError;
1895
2340
  let storeResolution = DEFAULT_RESOLUTION;
2341
+ let binRetentionMs = DEFAULT_RECYCLE_BIN_DAYS * DAY_MS;
2342
+ /**
2343
+ * The deleted spans of recorded tracks by context, loaded once and dropped
2344
+ * whenever a delete, restore or purge changes them. Read for every history
2345
+ * reconciliation, which would otherwise ask the store each time.
2346
+ */
2347
+ let deletedSpans;
2348
+ /** A vessel's deleted spans, purged ones included. */
2349
+ async function deletedSpansOf(context) {
2350
+ const store = tracks;
2351
+ if (!store?.deletedSpans) return [];
2352
+ const loading = deletedSpans ??= Promise.resolve(store.deletedSpans()).then((spans) => {
2353
+ const byContext = /* @__PURE__ */ new Map();
2354
+ for (const { context: owner, ...span } of spans) byContext.set(owner, [...byContext.get(owner) ?? [], span]);
2355
+ return byContext;
2356
+ });
2357
+ try {
2358
+ return (await loading).get(context) ?? [];
2359
+ } catch (err) {
2360
+ if (deletedSpans === loading) deletedSpans = void 0;
2361
+ throw err;
2362
+ }
2363
+ }
2364
+ /**
2365
+ * What history holds for a context, without what was deleted from it.
2366
+ *
2367
+ * The plugin cannot delete from a history provider, so a deleted span is
2368
+ * cut out of every answer it gives; the store's own rows went to the
2369
+ * recycle bin and are not read at all.
2370
+ */
2371
+ async function visibleHistory(context, window, resolutionMs) {
2372
+ const history = await historyPositions(app, context, window, resolutionMs, app.debug);
2373
+ if (history.points.length === 0) return history;
2374
+ const spans = await deletedSpansOf(context);
2375
+ return spans.length === 0 ? history : {
2376
+ ...history,
2377
+ points: outsideSpans(history.points, spans)
2378
+ };
2379
+ }
1896
2380
  const sourceWatch = new SourceWatch();
1897
2381
  let glitchFilter = new GlitchFilter({ maxSpeedKnots: 100 });
1898
2382
  let stateGate = new StateGate(app.selfContext, DEFAULT_PAUSE_STATES);
@@ -1925,6 +2409,8 @@ function ThePlugin(app) {
1925
2409
  storeResolution = toNumber(config.resolution) ?? DEFAULT_RESOLUTION;
1926
2410
  const segmentGapMinutes = toNumber(config.segmentGapMinutes) ?? DEFAULT_SEGMENT_GAP_MINUTES;
1927
2411
  segmentGap = segmentGapMinutes > 0 ? segmentGapMinutes * 60 * 1e3 : 0;
2412
+ binRetentionMs = Math.max(1, toNumber(config.recycleBinDays) ?? DEFAULT_RECYCLE_BIN_DAYS) * DAY_MS;
2413
+ deletedSpans = void 0;
1928
2414
  glitchFilter = new GlitchFilter({ maxSpeedKnots: toNumber(config.maxSpeedKnots) ?? 100 });
1929
2415
  stateGate = new StateGate(app.selfContext, Array.isArray(config.pauseWhenState) ? config.pauseWhenState : DEFAULT_PAUSE_STATES);
1930
2416
  const dataDir = app.getDataDirPath?.();
@@ -1938,6 +2424,7 @@ function ThePlugin(app) {
1938
2424
  file: join(dataDir, "tracks.db"),
1939
2425
  resolution: toNumber(resolution) ?? DEFAULT_RESOLUTION,
1940
2426
  retention: 0,
2427
+ binRetention: binRetentionMs,
1941
2428
  segmentGap
1942
2429
  }, app.debug, (error) => {
1943
2430
  if (tracks !== starting) return;
@@ -1991,9 +2478,13 @@ function ThePlugin(app) {
1991
2478
  segmentGap: () => segmentGap,
1992
2479
  contextName: (context) => contextName(context, remembering(() => tracks)),
1993
2480
  historyContexts: (window) => historyContextsIn(app, window, app.debug, knownContexts),
2481
+ historyKnows: (context) => historyKnowsContext(app, context, app.debug, knownContexts),
2482
+ deleted: () => {
2483
+ deletedSpans = void 0;
2484
+ },
1994
2485
  reconcileWithHistory: async (context, stored, window, resolutionMs) => {
1995
2486
  const effective = resolutionMs ?? storeResolution;
1996
- return withHistory(await historyPositions(app, context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective, app.debug), stored);
2487
+ return withHistory(await visibleHistory(context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective), stored);
1997
2488
  }
1998
2489
  }));
1999
2490
  return starting.ready.catch(async () => {
@@ -2043,7 +2534,7 @@ function ThePlugin(app) {
2043
2534
  points: [],
2044
2535
  known: false
2045
2536
  }));
2046
- const history = await historyPositions(app, context, query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effectiveResolution, app.debug);
2537
+ const history = await visibleHistory(context, query.window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effectiveResolution);
2047
2538
  const points = withHistory(history, stored);
2048
2539
  if (points.length === 0) {
2049
2540
  if (!(known || await historyKnowsContext(app, context, app.debug, knownContexts))) return;
@@ -2202,8 +2693,198 @@ function ThePlugin(app) {
2202
2693
  router.get("/tracks/*", allTracksHandler);
2203
2694
  return router;
2204
2695
  },
2696
+ registerWithRouter: function(router) {
2697
+ /** An import as the webapp lists it, in the shape of a v2 track's properties. */
2698
+ const importSummary = (track) => {
2699
+ const name = track.context === void 0 ? void 0 : contextName(track.context, remembering(() => tracks));
2700
+ let pointCount = 0;
2701
+ let from = Infinity;
2702
+ let to = -Infinity;
2703
+ for (const segment of track.segments) for (const { timestamp } of segment) {
2704
+ pointCount++;
2705
+ if (timestamp !== void 0) {
2706
+ from = Math.min(from, timestamp);
2707
+ to = Math.max(to, timestamp);
2708
+ }
2709
+ }
2710
+ return {
2711
+ id: `${PLUGIN_ID}:${track.id}`,
2712
+ providerId: PLUGIN_ID,
2713
+ ...track.name === void 0 ? {} : { name: track.name },
2714
+ ...track.context === void 0 ? {} : {
2715
+ context: track.context,
2716
+ isSelf: track.context === app.selfContext
2717
+ },
2718
+ ...name === void 0 ? {} : { contextName: name },
2719
+ ...from > to ? {} : {
2720
+ from: new Date(from).toISOString(),
2721
+ to: new Date(to).toISOString()
2722
+ },
2723
+ pointCount
2724
+ };
2725
+ };
2726
+ const binEntryJson = (entry) => {
2727
+ const name = entry.context === void 0 ? void 0 : contextName(entry.context, remembering(() => tracks));
2728
+ const iso = (ms) => ms === void 0 ? void 0 : new Date(ms).toISOString();
2729
+ return {
2730
+ id: entry.id,
2731
+ trackId: entry.importId ?? recordedId(entry.context),
2732
+ kind: entry.importId === void 0 ? "recorded" : "imported",
2733
+ ...entry.context === void 0 ? {} : {
2734
+ context: entry.context,
2735
+ isSelf: entry.context === app.selfContext
2736
+ },
2737
+ ...name === void 0 ? {} : { contextName: name },
2738
+ ...entry.name === void 0 ? {} : { name: entry.name },
2739
+ whole: entry.whole,
2740
+ ...entry.from === void 0 ? {} : { from: iso(entry.from) },
2741
+ ...entry.to === void 0 ? {} : { to: iso(entry.to) },
2742
+ deletedAt: iso(entry.deletedAt),
2743
+ purgeAt: iso(entry.deletedAt + binRetentionMs),
2744
+ pointCount: entry.pointCount
2745
+ };
2746
+ };
2747
+ /** The bin entry an `:id` names, or undefined after answering 400 or 404. */
2748
+ const binStore = (req, res) => {
2749
+ const store = tracks;
2750
+ if (!store?.binEntries || !store.restoreFromBin || !store.purgeFromBin) {
2751
+ notAvailable(res);
2752
+ return;
2753
+ }
2754
+ const id = Number(req.params.id);
2755
+ if (!Number.isSafeInteger(id) || id <= 0) {
2756
+ res.status(400).json({ message: `Recycle bin entry '${req.params.id}' is not an id` });
2757
+ return;
2758
+ }
2759
+ return {
2760
+ store,
2761
+ id
2762
+ };
2763
+ };
2764
+ const failed = (res) => (err) => {
2765
+ app.error(`Recycle bin: ${errorDetail(err)}`);
2766
+ res.status(500).json({ message: "Track storage failed" });
2767
+ };
2768
+ const readable = router.access?.("readonly") ?? router;
2769
+ readable.get("/imports", (_req, res) => {
2770
+ const store = tracks;
2771
+ if (!store?.findImports) {
2772
+ notAvailable(res);
2773
+ return;
2774
+ }
2775
+ Promise.resolve(store.findImports()).then((imports) => res.json(imports.map(importSummary))).catch((err) => {
2776
+ app.error(`Listing imports: ${errorDetail(err)}`);
2777
+ res.status(500).json({ message: "Track storage failed" });
2778
+ });
2779
+ });
2780
+ (router.access?.("readwrite") ?? router).post("/imports", (req, res) => {
2781
+ const store = tracks;
2782
+ if (!store?.storeImport || !store.recordTrack) {
2783
+ notAvailable(res);
2784
+ return;
2785
+ }
2786
+ const writableStore = {
2787
+ storeImport: store.storeImport.bind(store),
2788
+ recordTrack: store.recordTrack.bind(store)
2789
+ };
2790
+ const context = req.query.self === "true" ? app.selfContext : void 0;
2791
+ (async () => {
2792
+ let text;
2793
+ try {
2794
+ text = await readText(req);
2795
+ } catch (err) {
2796
+ if (err instanceof UploadTooLargeError) res.status(413).json({ message: err.message });
2797
+ else if (err instanceof UploadTypeError) res.status(415).json({ message: err.message });
2798
+ else res.status(400).json({ message: "The file could not be read" });
2799
+ return;
2800
+ }
2801
+ const read = fromGpx(text);
2802
+ if (read.length === 0) {
2803
+ res.status(400).json({ message: "No track found: the file is not GPX, or has no track with points" });
2804
+ return;
2805
+ }
2806
+ const converted = read.map((gpx) => fromGpxTrack(gpx, context ?? gpx.context));
2807
+ const ids = [];
2808
+ try {
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
+ }));
2817
+ } catch (err) {
2818
+ app.error(`GPX import: ${errorDetail(err)}`);
2819
+ res.status(500).json({ message: "Track storage failed" });
2820
+ return;
2821
+ }
2822
+ res.status(201).json({
2823
+ ids: ids.map((id) => `${PLUGIN_ID}:${id}`),
2824
+ skippedPoints: converted.reduce((sum, { skippedPoints }) => sum + skippedPoints, 0)
2825
+ });
2826
+ })();
2827
+ });
2828
+ readable.get("/imports/:id/track.gpx", (req, res) => {
2829
+ const store = tracks;
2830
+ if (!store?.getImport) {
2831
+ notAvailable(res);
2832
+ return;
2833
+ }
2834
+ const id = String(req.params.id);
2835
+ if (!isImportedId(id)) {
2836
+ res.status(404).json({ message: `No imported track '${id}'` });
2837
+ return;
2838
+ }
2839
+ Promise.resolve(store.getImport(id)).then((track) => {
2840
+ if (!track) {
2841
+ res.status(404).json({ message: `No imported track '${id}'` });
2842
+ return;
2843
+ }
2844
+ const label = track.name?.trim() || (track.context === void 0 ? "Imported track" : trackLabel(track.context, app.selfContext, remembering(() => tracks)));
2845
+ const filename = gpxFilename(label);
2846
+ res.type("application/gpx+xml");
2847
+ res.setHeader("Content-Disposition", `attachment; filename="${asciiFilename(filename)}"; filename*=UTF-8''${rfc8187(filename)}`);
2848
+ res.send(toGpx([{
2849
+ name: label,
2850
+ ...track.context === void 0 ? {} : { context: track.context },
2851
+ segments: track.segments
2852
+ }]));
2853
+ }).catch((err) => {
2854
+ app.error(`Could not export GPX for ${id}: ${errorDetail(err)}`);
2855
+ if (!res.headersSent) res.status(500).json({ message: `Could not export the track ${id}` });
2856
+ });
2857
+ });
2858
+ router.get("/recycle-bin", (_req, res) => {
2859
+ const store = tracks;
2860
+ if (!store?.binEntries) {
2861
+ notAvailable(res);
2862
+ return;
2863
+ }
2864
+ Promise.resolve(store.binEntries()).then((entries) => res.json(entries.map(binEntryJson))).catch(failed(res));
2865
+ });
2866
+ router.post("/recycle-bin/:id/restore", (req, res) => {
2867
+ const found = binStore(req, res);
2868
+ if (!found) return;
2869
+ Promise.resolve(found.store.restoreFromBin(found.id)).then((result) => {
2870
+ deletedSpans = void 0;
2871
+ if (result === "restored") res.json({});
2872
+ else if (result === "missing") res.status(404).json({ message: "No such entry in the recycle bin" });
2873
+ else res.status(409).json({ message: "The whole track is in the recycle bin too; restore it first" });
2874
+ }).catch(failed(res));
2875
+ });
2876
+ router.delete("/recycle-bin/:id", (req, res) => {
2877
+ const found = binStore(req, res);
2878
+ if (!found) return;
2879
+ Promise.resolve(found.store.purgeFromBin(found.id)).then((purged) => {
2880
+ if (purged) res.json({});
2881
+ else res.status(404).json({ message: "No such entry in the recycle bin" });
2882
+ }).catch(failed(res));
2883
+ });
2884
+ return router;
2885
+ },
2205
2886
  getTracks: () => tracks,
2206
- id: "tracks",
2887
+ id: PLUGIN_ID,
2207
2888
  name: "Tracks",
2208
2889
  enabledByDefault: true,
2209
2890
  description: "Record vessel tracks to SQLite and serve them through the track API",
@@ -2229,6 +2910,13 @@ function ThePlugin(app) {
2229
2910
  description: "A gap longer than this starts a new track segment, so a stop overnight or a spell out of AIS range does not draw a straight line across it. 0 (the default) returns the track as a single line, as before. Note that slow-updating AIS targets can legitimately go many minutes between fixes, so a low value will fragment their tracks.",
2230
2911
  default: DEFAULT_SEGMENT_GAP_MINUTES
2231
2912
  },
2913
+ recycleBinDays: {
2914
+ type: "integer",
2915
+ minimum: 1,
2916
+ title: "Days to keep a deleted track in the recycle bin",
2917
+ description: "A deleted track, or part of one, can be restored from the recycle bin in the Tracks webapp until it is purged after this many days.",
2918
+ default: DEFAULT_RECYCLE_BIN_DAYS
2919
+ },
2232
2920
  maxSpeedKnots: {
2233
2921
  type: "integer",
2234
2922
  title: "Discard positions implying a speed above this (knots)",