@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/README.md +37 -15
- package/dist/asyncTrackStore.d.ts +5 -4
- package/dist/import.js +27 -6
- package/dist/importedTracks.d.ts +2 -4
- package/dist/index.html +4 -0
- package/dist/index.js +266 -69
- package/dist/index.js.map +1 -1
- package/dist/sqliteStore.d.ts +12 -3
- package/dist/store.d.ts +8 -3
- package/dist/trackApi.d.ts +2 -0
- package/dist/trackProvider.d.ts +62 -2
- package/dist/trackStoreProtocol.d.ts +1 -1
- package/dist/trackStoreWorker.js +40 -3
- package/dist/trackStoreWorker.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/docs/history-and-storage.md +6 -1
- package/package.json +1 -1
- package/public/import.js +27 -6
- package/public/index.html +4 -0
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
|
-
*
|
|
151
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2176
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
|
2747
|
-
if (!
|
|
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
|
-
|
|
2769
|
-
|
|
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
|
|
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:
|
|
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
|
});
|