@signalk/tracks-plugin 3.3.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/README.md CHANGED
@@ -49,17 +49,22 @@ 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. 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.
52
+ posted. It carries `imported`, the time it was stored, which a recorded track does not, so a
53
+ client drawing a vessel's recording can leave the import out of it. Imports stored by a version
54
+ before that time was kept are dated to the first start of a version that keeps it.
54
55
 
55
56
  A timed track of the own vessel is the exception: it is the boat's own passage, logged before
56
57
  the plugin was installed or by another plotter, so it is written into the own vessel's
57
58
  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.
59
+ `tracks:recorded:<context>`. By default it only fills what the recording is missing: its
60
+ points are used where the vessel has no position, from this plugin or a history provider, for
61
+ at least five minutes, and left out elsewhere, so nothing recorded is overwritten and two
62
+ sources are never interleaved minute by minute; while a history provider cannot be read, such an
63
+ import is refused with a 503 rather than drawn over what it holds. Asked to replace instead, it takes the place of
64
+ what was recorded over the same time: the positions this plugin stored there go to the recycle
65
+ bin first, one entry per segment, and a history provider's data over that time is hidden while
66
+ the entry is there. Restoring the entry brings both back. An untimed track of the own vessel
67
+ stays an import, since it cannot be placed in time.
63
68
 
64
69
  A track with neither times (`coordTimes`) nor a vessel (`context`) is kept too. No time
65
70
  window or vessel query matches it, but `GET /plugins/tracks/imports` lists it with every other
@@ -72,15 +77,23 @@ with `POST /plugins/tracks/imports`, the file as the request body with `Content-
72
77
  application/gpx+xml`. Each track in the file becomes an imported track as above. Ticking
73
78
  **Tracks of my own vessel** (`?self=true`) files them under the own vessel, which writes a
74
79
  timed one into the recording; otherwise a track names a vessel only if the file says which, as
75
- the plugin's own GPX export does.
76
-
77
- In a timed track, points without a time are skipped, and the answer says how many. The route
80
+ the plugin's own GPX export does. Ticking **Replace what my vessel recorded over the same time**
81
+ as well (`onOverlap=replace`; the default is `keep`) replaces the recording rather than filling
82
+ it.
83
+
84
+ In a timed track, points without a time are skipped, and the answer says how many
85
+ (`skippedPoints`), as it does for the points left out where the recording already has positions
86
+ (`overlappingPoints`). A track posted through the Track API's `POST /signalk/v2/api/tracks` is
87
+ always kept beside the recording this way, since that API has no such choice yet. The route
78
88
  needs write access on a Signal K server that lets a plugin say so, and administrator rights on
79
89
  an older one.
80
90
 
81
91
  The webapp lists every imported track, however old and whether or not it has times, next to
82
92
  the last 30 days of recorded ones, so a passage imported from years ago can be found there. It
83
- can be deleted there on a Signal K server that deletes tracks by id. `GET /plugins/tracks/imports` returns every imported track, and no recorded ones.
93
+ can be deleted there on a Signal K server that deletes tracks by id.
94
+
95
+ `GET /plugins/tracks/imports` returns every imported track, and no recorded ones, each with
96
+ `imported`, the time it was stored.
84
97
 
85
98
  The Tracks webapp exports an imported track as GPX from its row, as it was imported. A client
86
99
  can download the same file from `GET /plugins/tracks/imports/<id>/track.gpx`, with the id less
@@ -79,8 +79,8 @@ export declare class AsyncTrackStore implements TrackStore {
79
79
  purgeFromBin(id: number): Promise<boolean>;
80
80
  binEntries(): Promise<import('./types.js').BinEntry[]>;
81
81
  deletedSpans(): Promise<import('./types.js').RecordedSpan[]>;
82
- getImport(id: string): Promise<ImportedTrack | undefined>;
83
- findImports(filter?: ImportFilter): Promise<ImportedTrack[]>;
82
+ getImport(id: string): Promise<import('./types.js').StoredImport | undefined>;
83
+ findImports(filter?: ImportFilter): Promise<import('./types.js').StoredImport[]>;
84
84
  close(): Promise<void>;
85
85
  }
86
86
  export {};
package/dist/import.js CHANGED
@@ -10,12 +10,26 @@ import { refusal } from './bin.js'
10
10
  /** Relative, because the server mounts this page under /@signalk/tracks-plugin. */
11
11
  const IMPORT_URL = '../../plugins/tracks/imports'
12
12
 
13
+ const points = (count) => `${count} point${count === 1 ? '' : 's'}`
14
+
13
15
  /** What an import came to, in words. */
14
- export function imported({ ids, skippedPoints }) {
15
- const count = `Imported ${ids.length} track${ids.length === 1 ? '' : 's'}.`
16
- return skippedPoints > 0
17
- ? `${count} ${skippedPoints} point${skippedPoints === 1 ? '' : 's'} without a time left out.`
18
- : count
16
+ export function imported({ ids, skippedPoints, overlappingPoints = 0 }) {
17
+ return [
18
+ `Imported ${ids.length} track${ids.length === 1 ? '' : 's'}.`,
19
+ ...(skippedPoints > 0 ? [`${points(skippedPoints)} without a time left out.`] : []),
20
+ ...(overlappingPoints > 0 ? [`${points(overlappingPoints)} left out where your vessel already has a track.`] : []),
21
+ ].join(' ')
22
+ }
23
+
24
+ /**
25
+ * The upload's query. Only the own vessel's tracks go into a recording, so
26
+ * replacing is offered for those alone; the route keeps by default.
27
+ */
28
+ export function importQuery({ self, replace }) {
29
+ if (!self) {
30
+ return ''
31
+ }
32
+ return replace ? '?self=true&onOverlap=replace' : '?self=true'
19
33
  }
20
34
 
21
35
  async function messageOf(response) {
@@ -31,6 +45,13 @@ export function start() {
31
45
  const form = document.getElementById('import-form')
32
46
  const status = document.getElementById('import-status')
33
47
  const button = form.querySelector('button[type="submit"]')
48
+ const { self, replace } = form.elements
49
+ // Only the own vessel has a recording to replace.
50
+ const allowReplace = () => {
51
+ replace.disabled = !self.checked
52
+ }
53
+ allowReplace()
54
+ self.addEventListener('change', allowReplace)
34
55
 
35
56
  function show(message, state) {
36
57
  // A failed upload can be tried again; a successful one reloads the page.
@@ -58,7 +79,7 @@ export function start() {
58
79
  show('Choose a GPX file first.', 'error')
59
80
  return
60
81
  }
61
- const query = form.elements.self.checked ? '?self=true' : ''
82
+ const query = importQuery({ self: self.checked, replace: replace.checked })
62
83
  button.disabled = true
63
84
  show('Importing…')
64
85
  let response
package/dist/index.html CHANGED
@@ -31,6 +31,10 @@
31
31
  <form id="import-form">
32
32
  <label>GPX file <input type="file" name="file" accept=".gpx,application/gpx+xml" required /></label>
33
33
  <label><input type="checkbox" name="self" /> Tracks of my own vessel</label>
34
+ <label
35
+ ><input type="checkbox" name="replace" /> Replace what my vessel recorded over the same time (it goes to the
36
+ recycle bin)</label
37
+ >
34
38
  <button type="submit">Import</button>
35
39
  </form>
36
40
  <p id="import-status" role="status" hidden></p>
package/dist/index.js CHANGED
@@ -445,14 +445,14 @@ function createTrackProvider(deps) {
445
445
  };
446
446
  /**
447
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.
448
+ * `imported`, when it was kept, tells it apart from the recording of the same vessel, which it is listed beside.
449
449
  */
450
450
  const importIdentity = (track) => {
451
451
  const contextName = track.context === void 0 ? void 0 : deps.contextName(track.context);
452
452
  return {
453
453
  ...track.metadata,
454
454
  id: track.id,
455
- imported: true,
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,
@@ -467,6 +467,16 @@ function createTrackProvider(deps) {
467
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,13 +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
- return keepTrack(toImportedTrack(track), {
541
- store: writableStore(),
542
- selfContext: deps.selfContext(),
543
- historyKnows: deps.historyKnows,
544
- deleted: deps.deleted
545
- });
550
+ return (await importTrack(toImportedTrack(track), "keep")).id;
546
551
  },
552
+ importTrack,
547
553
  async deleteTrack(id) {
548
554
  const recorded = recordedContext(id);
549
555
  if (recorded !== void 0) return deleteRecorded(resolveSelf(deps.selfContext())(recorded), void 0, void 0);
@@ -673,28 +679,127 @@ function extentEpsilon([west, south, east, north]) {
673
679
  return Math.max(Math.hypot(width, height) / AUTO_EPSILON_DIVISOR, MIN_AUTO_EPSILON);
674
680
  }
675
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
+ /**
676
689
  * Keep a client's track, resolving to the id that addresses it.
677
690
  *
678
691
  * A timed track of the own vessel is the vessel's own passage, from before
679
692
  * 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.
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.
684
698
  */
685
- async function keepTrack(track, deps) {
699
+ async function keepTrack(track, deps, onOverlap) {
686
700
  const context = track.context === void 0 ? void 0 : resolveSelf(deps.selfContext)(track.context);
687
701
  if (context === deps.selfContext && isTimed(track)) {
688
702
  const segments = track.segments.map((points) => points.map(({ position, timestamp }) => ({
689
703
  position,
690
704
  timestamp
691
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
+ }
692
733
  await deps.store.recordTrack(context, segments, Date.now(), await deps.historyKnows(context));
693
734
  deps.deleted();
694
- return recordedId(context);
735
+ return {
736
+ id: recordedId(context),
737
+ overlapping: 0
738
+ };
695
739
  }
696
740
  await deps.store.storeImport(track);
697
- return track.id;
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
+ };
698
803
  }
699
804
  /** v2 accepts the `self` alias; the store keys on the qualified context. */
700
805
  var resolveSelf = (selfContext) => (context) => context === "self" || context === "vessels.self" ? selfContext : context;
@@ -2073,6 +2178,14 @@ var KNOWN_CONTEXTS_TTL_MS = 3e4;
2073
2178
  */
2074
2179
  var KNOWN_CONTEXTS_FAILURE_TTL_MS = 1e3;
2075
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
+ /**
2076
2189
  * Config values arrive from the plugin UI as numbers, but a hand-edited
2077
2190
  * settings file can supply strings. Accept both, reject anything non-finite so
2078
2191
  * a bad value falls back to the default instead of poisoning arithmetic with NaN.
@@ -2161,35 +2274,50 @@ var toContextSet = (listed, selfContext) => new Set((listed ?? []).filter((c) =>
2161
2274
  * request open.
2162
2275
  */
2163
2276
  async function historyKnowsContext(app, context, debug, cache) {
2164
- const getHistoryApi = app.getHistoryApi;
2165
- if (!getHistoryApi) return false;
2166
2277
  try {
2167
- const now = Date.now();
2168
- const cached = cache.current;
2169
- if (cached === void 0 || now - cached.at >= KNOWN_CONTEXTS_TTL_MS) {
2170
- const contexts = withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider).then((historyApi) => historyApi.getContexts ? historyApi.getContexts({
2171
- from: Temporal.Instant.from((/* @__PURE__ */ new Date(EXISTENCE_PROBE_FROM_MS)).toISOString()),
2172
- to: Temporal.Instant.from(new Date(now).toISOString())
2173
- }) : []).then((listed) => toContextSet(listed, app.selfContext)), HISTORY_QUERY_TIMEOUT_MS);
2174
- const entry = {
2175
- at: now,
2176
- contexts
2177
- };
2178
- contexts.catch(() => {
2179
- if (cache.current === entry) cache.current = {
2180
- at: now - KNOWN_CONTEXTS_TTL_MS + KNOWN_CONTEXTS_FAILURE_TTL_MS,
2181
- contexts
2182
- };
2183
- });
2184
- cache.current = entry;
2185
- }
2186
- return (await cache.current.contexts).has(context);
2278
+ return (await knownHistoryContexts(app, cache)).has(context);
2187
2279
  } catch (err) {
2188
2280
  debug(`History contexts unavailable for ${context}: ${errorDetail(err)}`);
2189
2281
  return false;
2190
2282
  }
2191
2283
  }
2192
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
+ /**
2193
2321
  * Contexts a history provider holds data for within a window, or none.
2194
2322
  *
2195
2323
  * What lets the v2 provider return a vessel the store has never seen, or match
@@ -2206,16 +2334,30 @@ async function historyContextsIn(app, window, debug, cache) {
2206
2334
  const getHistoryApi = app.getHistoryApi;
2207
2335
  if (!getHistoryApi) return /* @__PURE__ */ new Set();
2208
2336
  try {
2209
- if (!window) {
2210
- await historyKnowsContext(app, app.selfContext, debug, cache);
2211
- 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();
2212
2349
  }
2213
2350
  const historyApi = await withTimeout(getHistoryApi(app.config?.settings?.historyApi?.defaultProvider), HISTORY_QUERY_TIMEOUT_MS);
2214
2351
  if (!historyApi.getContexts) return /* @__PURE__ */ new Set();
2215
- return toContextSet(await withTimeout(historyApi.getContexts({
2352
+ const query = historyApi.getContexts({
2216
2353
  from: Temporal.Instant.from(new Date(Math.max(window.from, EXISTENCE_PROBE_FROM_MS)).toISOString()),
2217
2354
  to: Temporal.Instant.from(new Date(window.to).toISOString())
2218
- }), 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);
2219
2361
  } catch (err) {
2220
2362
  if (debug.enabled) debug(`History contexts unavailable: ${errorDetail(err)}`);
2221
2363
  return /* @__PURE__ */ new Set();
@@ -2328,6 +2470,8 @@ function ThePlugin(app) {
2328
2470
  };
2329
2471
  let onStop = [];
2330
2472
  let tracks = void 0;
2473
+ /** Also what the plugin's own import route keeps tracks through. */
2474
+ let trackProvider = void 0;
2331
2475
  let segmentGap = 0;
2332
2476
  /**
2333
2477
  * How often the store keeps a position, in ms.
@@ -2472,7 +2616,7 @@ function ThePlugin(app) {
2472
2616
  onStop.push(() => {
2473
2617
  clearInterval(statusInterval);
2474
2618
  });
2475
- app.registerTrackApiProvider?.(createTrackProvider({
2619
+ trackProvider = createTrackProvider({
2476
2620
  store: () => tracks,
2477
2621
  selfContext: () => app.selfContext,
2478
2622
  segmentGap: () => segmentGap,
@@ -2485,8 +2629,20 @@ function ThePlugin(app) {
2485
2629
  reconcileWithHistory: async (context, stored, window, resolutionMs) => {
2486
2630
  const effective = resolutionMs ?? storeResolution;
2487
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];
2488
2643
  }
2489
- }));
2644
+ });
2645
+ app.registerTrackApiProvider?.(trackProvider);
2490
2646
  return starting.ready.catch(async () => {
2491
2647
  await starting.close().catch(() => {});
2492
2648
  if (tracks === starting) tracks = void 0;
@@ -2505,6 +2661,7 @@ function ThePlugin(app) {
2505
2661
  glitchFilter.clear();
2506
2662
  stateGate.clear();
2507
2663
  knownContexts.current = void 0;
2664
+ delete knownContexts.windowQueries;
2508
2665
  const closing = tracks;
2509
2666
  tracks = void 0;
2510
2667
  try {
@@ -2720,6 +2877,7 @@ function ThePlugin(app) {
2720
2877
  from: new Date(from).toISOString(),
2721
2878
  to: new Date(to).toISOString()
2722
2879
  },
2880
+ imported: new Date(track.importedAt).toISOString(),
2723
2881
  pointCount
2724
2882
  };
2725
2883
  };
@@ -2778,16 +2936,17 @@ function ThePlugin(app) {
2778
2936
  });
2779
2937
  });
2780
2938
  (router.access?.("readwrite") ?? router).post("/imports", (req, res) => {
2781
- const store = tracks;
2782
- if (!store?.storeImport || !store.recordTrack) {
2939
+ const provider = trackProvider;
2940
+ if (!provider || !tracks?.storeImport || !tracks.recordTrack) {
2783
2941
  notAvailable(res);
2784
2942
  return;
2785
2943
  }
2786
- const writableStore = {
2787
- storeImport: store.storeImport.bind(store),
2788
- recordTrack: store.recordTrack.bind(store)
2789
- };
2790
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
+ }
2791
2950
  (async () => {
2792
2951
  let text;
2793
2952
  try {
@@ -2804,24 +2963,22 @@ function ThePlugin(app) {
2804
2963
  return;
2805
2964
  }
2806
2965
  const converted = read.map((gpx) => fromGpxTrack(gpx, context ?? gpx.context));
2807
- const ids = [];
2966
+ const kept = [];
2808
2967
  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
- }));
2968
+ for (const { track } of converted) kept.push(await provider.importTrack(track, onOverlap));
2817
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
+ }
2818
2974
  app.error(`GPX import: ${errorDetail(err)}`);
2819
2975
  res.status(500).json({ message: "Track storage failed" });
2820
2976
  return;
2821
2977
  }
2822
2978
  res.status(201).json({
2823
- ids: ids.map((id) => `${PLUGIN_ID}:${id}`),
2824
- 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)
2825
2982
  });
2826
2983
  })();
2827
2984
  });