@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 +24 -11
- package/dist/asyncTrackStore.d.ts +2 -2
- package/dist/import.js +27 -6
- package/dist/index.html +4 -0
- package/dist/index.js +218 -61
- package/dist/index.js.map +1 -1
- package/dist/sqliteStore.d.ts +3 -3
- package/dist/store.d.ts +3 -3
- package/dist/trackApi.d.ts +2 -2
- package/dist/trackProvider.d.ts +49 -6
- package/dist/trackStoreWorker.js +18 -3
- package/dist/trackStoreWorker.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/docs/history-and-storage.md +5 -3
- package/package.json +1 -1
- package/public/import.js +27 -6
- package/public/index.html +4 -0
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
|
|
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>`.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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.
|
|
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<
|
|
83
|
-
findImports(filter?: ImportFilter): Promise<
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
:
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
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
|
|
735
|
+
return {
|
|
736
|
+
id: recordedId(context),
|
|
737
|
+
overlapping: 0
|
|
738
|
+
};
|
|
695
739
|
}
|
|
696
740
|
await deps.store.storeImport(track);
|
|
697
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
2211
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
|
2782
|
-
if (!
|
|
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
|
|
2966
|
+
const kept = [];
|
|
2808
2967
|
try {
|
|
2809
|
-
for (const { track } of converted)
|
|
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:
|
|
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
|
});
|