@signalk/tracks-plugin 3.2.0 → 3.4.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
@@ -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`, when it was kept, 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: new Date(track.importedAt).toISOString(),
456
456
  ...track.name === void 0 ? {} : { name: track.name },
457
457
  ...track.context === void 0 ? {} : {
458
458
  context: track.context,
@@ -464,9 +464,19 @@ 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
+ const importTrack = (track, onOverlap) => keepTrack(track, {
471
+ store: writableStore(),
472
+ selfContext: deps.selfContext(),
473
+ historyKnows: deps.historyKnows,
474
+ recorded: (context, from, to) => deps.recordedOver(context, {
475
+ from,
476
+ to
477
+ }),
478
+ deleted: deps.deleted
479
+ }, onOverlap);
470
480
  /** The recorded feature's identity, which `getTracks` and `getTrack` share. */
471
481
  const recordedIdentity = (context) => {
472
482
  const name = deps.contextName(context);
@@ -537,10 +547,9 @@ function createTrackProvider(deps) {
537
547
  return shapeFeature(positionsOf(track, null), 0, { times: timed }, void 0, importIdentity(track), timed);
538
548
  },
539
549
  async storeTrack(track) {
540
- const imported = toImportedTrack(track);
541
- await writableStore().storeImport(imported);
542
- return imported.id;
550
+ return (await importTrack(toImportedTrack(track), "keep")).id;
543
551
  },
552
+ importTrack,
544
553
  async deleteTrack(id) {
545
554
  const recorded = recordedContext(id);
546
555
  if (recorded !== void 0) return deleteRecorded(resolveSelf(deps.selfContext())(recorded), void 0, void 0);
@@ -669,6 +678,129 @@ function extentEpsilon([west, south, east, north]) {
669
678
  const width = (east >= west ? east - west : east + 360 - west) * M_PER_DEG * Math.cos(midLat * Math.PI / 180);
670
679
  return Math.max(Math.hypot(width, height) / AUTO_EPSILON_DIVISOR, MIN_AUTO_EPSILON);
671
680
  }
681
+ /**
682
+ * The shortest stretch without positions that an import kept beside a
683
+ * recording may fill. Filling every pause between recorded points would
684
+ * interleave two sources minute by minute, and any offset between them would
685
+ * draw as a sawtooth; whole stretches keep each part of the line from one.
686
+ */
687
+ var MIN_FILLED_GAP_MS = 3e5;
688
+ /**
689
+ * Keep a client's track, resolving to the id that addresses it.
690
+ *
691
+ * A timed track of the own vessel is the vessel's own passage, from before
692
+ * the plugin recorded it or from another plotter, so it is written into the
693
+ * recording and answers as `recorded:<context>`: with `keep`, only where the
694
+ * recording has nothing (see {@link fillGaps}); with `replace`, in place of
695
+ * what was recorded over the same time. Everything else is kept as an import:
696
+ * another vessel's track is not this boat's recording, and an untimed one
697
+ * cannot be placed in it.
698
+ */
699
+ async function keepTrack(track, deps, onOverlap) {
700
+ const context = track.context === void 0 ? void 0 : resolveSelf(deps.selfContext)(track.context);
701
+ if (context === deps.selfContext && isTimed(track)) {
702
+ const segments = track.segments.map((points) => points.map(({ position, timestamp }) => ({
703
+ position,
704
+ timestamp
705
+ })));
706
+ if (onOverlap === "keep") {
707
+ const runs = [];
708
+ const filled = [];
709
+ let overlapping = 0;
710
+ for (const points of segments) {
711
+ if (points.length === 0) continue;
712
+ let from = Infinity;
713
+ let to = -Infinity;
714
+ for (const { timestamp } of points) {
715
+ from = Math.min(from, timestamp);
716
+ to = Math.max(to, timestamp);
717
+ }
718
+ from -= MIN_FILLED_GAP_MS;
719
+ to += MIN_FILLED_GAP_MS;
720
+ const recorded = await deps.recorded(context, from, to);
721
+ for (const point of filled) if (point.timestamp >= from && point.timestamp <= to) recorded.push(point);
722
+ const kept = fillGaps([points], recorded, MIN_FILLED_GAP_MS);
723
+ runs.push(...kept.runs);
724
+ filled.push(...kept.runs.flat());
725
+ overlapping += kept.overlapping;
726
+ }
727
+ await deps.store.recordTrack(context, runs, Date.now(), false);
728
+ return {
729
+ id: recordedId(context),
730
+ overlapping
731
+ };
732
+ }
733
+ await deps.store.recordTrack(context, segments, Date.now(), await deps.historyKnows(context));
734
+ deps.deleted();
735
+ return {
736
+ id: recordedId(context),
737
+ overlapping: 0
738
+ };
739
+ }
740
+ await deps.store.storeImport(track);
741
+ return {
742
+ id: track.id,
743
+ overlapping: 0
744
+ };
745
+ }
746
+ /**
747
+ * The parts of an import that fall where a recording has nothing for at least
748
+ * `minGap`, as runs to record, and how many points were left out.
749
+ *
750
+ * Each recorded point covers its own instant, or a history bucket's whole
751
+ * span, so a coarse provider read still counts as covering the time between
752
+ * its buckets. A run never crosses a recorded stretch or a segment boundary.
753
+ */
754
+ function fillGaps(segments, recorded, minGap) {
755
+ const covered = [];
756
+ for (const { timestamp, span } of [...recorded].sort((a, b) => a.timestamp - b.timestamp)) {
757
+ const end = timestamp + (span ?? 0);
758
+ const last = covered.at(-1);
759
+ if (last && timestamp <= last[1]) last[1] = Math.max(last[1], end);
760
+ else covered.push([timestamp, end]);
761
+ }
762
+ /** Index of the first covered stretch ending at or after `time`. */
763
+ const nextStretch = (time) => {
764
+ let low = 0;
765
+ let high = covered.length;
766
+ while (low < high) {
767
+ const mid = low + high >> 1;
768
+ if (covered[mid][1] < time) low = mid + 1;
769
+ else high = mid;
770
+ }
771
+ return low;
772
+ };
773
+ const runs = [];
774
+ let overlapping = 0;
775
+ for (const points of segments) {
776
+ let run = [];
777
+ let runGap = -1;
778
+ for (const point of points) {
779
+ const next = nextStretch(point.timestamp);
780
+ const after = covered[next];
781
+ const before = covered[next - 1];
782
+ const inside = after !== void 0 && after[0] <= point.timestamp;
783
+ const free = (after?.[0] ?? Infinity) - (before?.[1] ?? -Infinity);
784
+ if (inside || free < minGap) {
785
+ overlapping += 1;
786
+ if (run.length > 0) runs.push(run);
787
+ run = [];
788
+ continue;
789
+ }
790
+ if (run.length > 0 && next !== runGap) {
791
+ runs.push(run);
792
+ run = [];
793
+ }
794
+ run.push(point);
795
+ runGap = next;
796
+ }
797
+ if (run.length > 0) runs.push(run);
798
+ }
799
+ return {
800
+ runs,
801
+ overlapping
802
+ };
803
+ }
672
804
  /** v2 accepts the `self` alias; the store keys on the qualified context. */
673
805
  var resolveSelf = (selfContext) => (context) => context === "self" || context === "vessels.self" ? selfContext : context;
674
806
  //#endregion
@@ -1002,6 +1134,14 @@ var AsyncTrackStore = class {
1002
1134
  keepEmpty
1003
1135
  ], "acknowledged");
1004
1136
  }
1137
+ recordTrack(context, segments, deletedAt, keepEmpty) {
1138
+ return this.enqueue("recordTrack", [
1139
+ context,
1140
+ segments,
1141
+ deletedAt,
1142
+ keepEmpty
1143
+ ], "acknowledged");
1144
+ }
1005
1145
  binImport(importId, span, deletedAt) {
1006
1146
  return this.enqueue("binImport", [
1007
1147
  importId,
@@ -2038,6 +2178,14 @@ var KNOWN_CONTEXTS_TTL_MS = 3e4;
2038
2178
  */
2039
2179
  var KNOWN_CONTEXTS_FAILURE_TTL_MS = 1e3;
2040
2180
  /**
2181
+ * How long a context query may stay unanswered before another is started
2182
+ * beside it. Waiting for the provider to settle is what keeps a slow one from
2183
+ * running a new epoch-wide scan every few seconds; the bound only covers a
2184
+ * provider whose promise never settles at all, which would otherwise leave
2185
+ * the list unknown until a restart.
2186
+ */
2187
+ var KNOWN_CONTEXTS_PENDING_MAX_MS = 3e5;
2188
+ /**
2041
2189
  * Config values arrive from the plugin UI as numbers, but a hand-edited
2042
2190
  * settings file can supply strings. Accept both, reject anything non-finite so
2043
2191
  * a bad value falls back to the default instead of poisoning arithmetic with NaN.
@@ -2126,35 +2274,50 @@ var toContextSet = (listed, selfContext) => new Set((listed ?? []).filter((c) =>
2126
2274
  * request open.
2127
2275
  */
2128
2276
  async function historyKnowsContext(app, context, debug, cache) {
2129
- const getHistoryApi = app.getHistoryApi;
2130
- if (!getHistoryApi) return false;
2131
2277
  try {
2132
- const now = Date.now();
2133
- const cached = cache.current;
2134
- if (cached === void 0 || now - cached.at >= KNOWN_CONTEXTS_TTL_MS) {
2135
- const contexts = withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider).then((historyApi) => historyApi.getContexts ? historyApi.getContexts({
2136
- from: Temporal.Instant.from((/* @__PURE__ */ new Date(EXISTENCE_PROBE_FROM_MS)).toISOString()),
2137
- to: Temporal.Instant.from(new Date(now).toISOString())
2138
- }) : []).then((listed) => toContextSet(listed, app.selfContext)), HISTORY_QUERY_TIMEOUT_MS);
2139
- const entry = {
2140
- at: now,
2141
- contexts
2142
- };
2143
- contexts.catch(() => {
2144
- if (cache.current === entry) cache.current = {
2145
- at: now - KNOWN_CONTEXTS_TTL_MS + KNOWN_CONTEXTS_FAILURE_TTL_MS,
2146
- contexts
2147
- };
2148
- });
2149
- cache.current = entry;
2150
- }
2151
- return (await cache.current.contexts).has(context);
2278
+ return (await knownHistoryContexts(app, cache)).has(context);
2152
2279
  } catch (err) {
2153
2280
  debug(`History contexts unavailable for ${context}: ${errorDetail(err)}`);
2154
2281
  return false;
2155
2282
  }
2156
2283
  }
2157
2284
  /**
2285
+ * Every context a history provider holds anything for, from the cache.
2286
+ *
2287
+ * Reused while fresh, and shared while in flight: a burst of misses is one
2288
+ * question to the provider, not one each. The promise is cached rather than
2289
+ * its result so concurrent callers join the same query instead of starting
2290
+ * their own, and a query is shared until the provider answers it, not only
2291
+ * until the caller's timeout: the provider keeps running a query nobody waits
2292
+ * for, so asking again on every timeout stacks scans of the whole database.
2293
+ */
2294
+ function knownHistoryContexts(app, cache) {
2295
+ const getHistoryApi = app.getHistoryApi;
2296
+ if (!getHistoryApi) return Promise.resolve(/* @__PURE__ */ new Set());
2297
+ const now = Date.now();
2298
+ const cached = cache.current;
2299
+ if (cached === void 0 || (cached.pending ? now - cached.at >= KNOWN_CONTEXTS_PENDING_MAX_MS : now - cached.at >= KNOWN_CONTEXTS_TTL_MS)) {
2300
+ const contexts = getHistoryApi(app.config?.settings?.historyApi?.defaultProvider).then((historyApi) => historyApi.getContexts ? historyApi.getContexts({
2301
+ from: Temporal.Instant.from((/* @__PURE__ */ new Date(EXISTENCE_PROBE_FROM_MS)).toISOString()),
2302
+ to: Temporal.Instant.from(new Date(now).toISOString())
2303
+ }) : []).then((listed) => toContextSet(listed, app.selfContext));
2304
+ const entry = {
2305
+ at: now,
2306
+ contexts,
2307
+ pending: true
2308
+ };
2309
+ contexts.then(() => {
2310
+ entry.pending = false;
2311
+ entry.at = Date.now();
2312
+ }, () => {
2313
+ entry.pending = false;
2314
+ entry.at = Date.now() - KNOWN_CONTEXTS_TTL_MS + KNOWN_CONTEXTS_FAILURE_TTL_MS;
2315
+ });
2316
+ cache.current = entry;
2317
+ }
2318
+ return withTimeout(cache.current.contexts, HISTORY_QUERY_TIMEOUT_MS);
2319
+ }
2320
+ /**
2158
2321
  * Contexts a history provider holds data for within a window, or none.
2159
2322
  *
2160
2323
  * What lets the v2 provider return a vessel the store has never seen, or match
@@ -2171,16 +2334,30 @@ async function historyContextsIn(app, window, debug, cache) {
2171
2334
  const getHistoryApi = app.getHistoryApi;
2172
2335
  if (!getHistoryApi) return /* @__PURE__ */ new Set();
2173
2336
  try {
2174
- if (!window) {
2175
- await historyKnowsContext(app, app.selfContext, debug, cache);
2176
- return await cache.current?.contexts ?? /* @__PURE__ */ new Set();
2337
+ if (!window) return await knownHistoryContexts(app, cache);
2338
+ const running = cache.windowQueries ??= /* @__PURE__ */ new Set();
2339
+ const now = Date.now();
2340
+ let slow = false;
2341
+ for (const entry of running) {
2342
+ const age = now - entry.startedAt;
2343
+ if (age >= KNOWN_CONTEXTS_PENDING_MAX_MS) running.delete(entry);
2344
+ else if (age >= HISTORY_QUERY_TIMEOUT_MS) slow = true;
2345
+ }
2346
+ if (slow) {
2347
+ if (debug.enabled) debug("History contexts skipped: an earlier windowed query is still running");
2348
+ return /* @__PURE__ */ new Set();
2177
2349
  }
2178
2350
  const historyApi = await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS);
2179
2351
  if (!historyApi.getContexts) return /* @__PURE__ */ new Set();
2180
- return toContextSet(await withTimeout(historyApi.getContexts({
2352
+ const query = historyApi.getContexts({
2181
2353
  from: Temporal.Instant.from(new Date(Math.max(window.from, EXISTENCE_PROBE_FROM_MS)).toISOString()),
2182
2354
  to: Temporal.Instant.from(new Date(window.to).toISOString())
2183
- }), HISTORY_QUERY_TIMEOUT_MS), app.selfContext);
2355
+ });
2356
+ const entry = { startedAt: Date.now() };
2357
+ running.add(entry);
2358
+ const settled = () => running.delete(entry);
2359
+ query.then(settled, settled);
2360
+ return toContextSet(await withTimeout(query, HISTORY_QUERY_TIMEOUT_MS), app.selfContext);
2184
2361
  } catch (err) {
2185
2362
  if (debug.enabled) debug(`History contexts unavailable: ${errorDetail(err)}`);
2186
2363
  return /* @__PURE__ */ new Set();
@@ -2293,6 +2470,8 @@ function ThePlugin(app) {
2293
2470
  };
2294
2471
  let onStop = [];
2295
2472
  let tracks = void 0;
2473
+ /** Also what the plugin's own import route keeps tracks through. */
2474
+ let trackProvider = void 0;
2296
2475
  let segmentGap = 0;
2297
2476
  /**
2298
2477
  * How often the store keeps a position, in ms.
@@ -2437,7 +2616,7 @@ function ThePlugin(app) {
2437
2616
  onStop.push(() => {
2438
2617
  clearInterval(statusInterval);
2439
2618
  });
2440
- app.registerTrackApiProvider?.(createTrackProvider({
2619
+ trackProvider = createTrackProvider({
2441
2620
  store: () => tracks,
2442
2621
  selfContext: () => app.selfContext,
2443
2622
  segmentGap: () => segmentGap,
@@ -2450,8 +2629,20 @@ function ThePlugin(app) {
2450
2629
  reconcileWithHistory: async (context, stored, window, resolutionMs) => {
2451
2630
  const effective = resolutionMs ?? storeResolution;
2452
2631
  return withHistory(await visibleHistory(context, window ?? windowSpanning(stored, WINDOWLESS_HISTORY_SPAN_MS), effective), stored);
2632
+ },
2633
+ recordedOver: async (context, window) => {
2634
+ const stored = (await tracks?.getFilteredTimedTracks({
2635
+ bbox: null,
2636
+ radius: null,
2637
+ intersects: true,
2638
+ contexts: [context]
2639
+ }, void 0, void 0, { window }))?.[context] ?? [];
2640
+ const history = await visibleHistory(context, window, storeResolution);
2641
+ if (history.failed) throw new HistoryUnavailableError(`History provider could not be read for ${context}`);
2642
+ return [...stored, ...history.points];
2453
2643
  }
2454
- }));
2644
+ });
2645
+ app.registerTrackApiProvider?.(trackProvider);
2455
2646
  return starting.ready.catch(async () => {
2456
2647
  await starting.close().catch(() => {});
2457
2648
  if (tracks === starting) tracks = void 0;
@@ -2470,6 +2661,7 @@ function ThePlugin(app) {
2470
2661
  glitchFilter.clear();
2471
2662
  stateGate.clear();
2472
2663
  knownContexts.current = void 0;
2664
+ delete knownContexts.windowQueries;
2473
2665
  const closing = tracks;
2474
2666
  tracks = void 0;
2475
2667
  try {
@@ -2685,6 +2877,7 @@ function ThePlugin(app) {
2685
2877
  from: new Date(from).toISOString(),
2686
2878
  to: new Date(to).toISOString()
2687
2879
  },
2880
+ imported: new Date(track.importedAt).toISOString(),
2688
2881
  pointCount
2689
2882
  };
2690
2883
  };
@@ -2743,13 +2936,17 @@ function ThePlugin(app) {
2743
2936
  });
2744
2937
  });
2745
2938
  (router.access?.("readwrite") ?? router).post("/imports", (req, res) => {
2746
- const store = tracks;
2747
- if (!store?.storeImport) {
2939
+ const provider = trackProvider;
2940
+ if (!provider || !tracks?.storeImport || !tracks.recordTrack) {
2748
2941
  notAvailable(res);
2749
2942
  return;
2750
2943
  }
2751
- const storeImport = store.storeImport.bind(store);
2752
2944
  const context = req.query.self === "true" ? app.selfContext : void 0;
2945
+ const onOverlap = req.query.onOverlap ?? "keep";
2946
+ if (onOverlap !== "keep" && onOverlap !== "replace") {
2947
+ res.status(400).json({ message: "onOverlap must be 'keep' or 'replace'" });
2948
+ return;
2949
+ }
2753
2950
  (async () => {
2754
2951
  let text;
2755
2952
  try {
@@ -2765,23 +2962,23 @@ function ThePlugin(app) {
2765
2962
  res.status(400).json({ message: "No track found: the file is not GPX, or has no track with points" });
2766
2963
  return;
2767
2964
  }
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
- }
2965
+ const converted = read.map((gpx) => fromGpxTrack(gpx, context ?? gpx.context));
2966
+ const kept = [];
2775
2967
  try {
2776
- for (const { track } of converted) await storeImport(track);
2968
+ for (const { track } of converted) kept.push(await provider.importTrack(track, onOverlap));
2777
2969
  } catch (err) {
2970
+ if (err instanceof HistoryUnavailableError) {
2971
+ res.status(503).json({ message: "The history provider could not be read, so the gaps to fill are unknown; try again later" });
2972
+ return;
2973
+ }
2778
2974
  app.error(`GPX import: ${errorDetail(err)}`);
2779
2975
  res.status(500).json({ message: "Track storage failed" });
2780
2976
  return;
2781
2977
  }
2782
2978
  res.status(201).json({
2783
- ids: converted.map(({ track }) => `${PLUGIN_ID}:${track.id}`),
2784
- skippedPoints: converted.reduce((sum, { skippedPoints }) => sum + skippedPoints, 0)
2979
+ ids: kept.map(({ id }) => `${PLUGIN_ID}:${id}`),
2980
+ skippedPoints: converted.reduce((sum, { skippedPoints }) => sum + skippedPoints, 0),
2981
+ overlappingPoints: kept.reduce((sum, { overlapping }) => sum + overlapping, 0)
2785
2982
  });
2786
2983
  })();
2787
2984
  });