mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-01 12:17:18 +00:00
refactoring
This commit is contained in:
1 parent
4795513344
commit
890bcd765a
169 files changed
+263
-256
No files matched your search
@@ -0,0 +1,143 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
|
||||
import { cachedFetch, pruneCache } from '$lib/storage/cached-fetch';
|
||||
|
||||
const keyOf = (request: RequestInfo | URL) =>
|
||||
request instanceof Request ? request.url : String(request);
|
||||
|
||||
/** A Cache API in memory: only what `cachedFetch` uses. */
|
||||
class FakeCache {
|
||||
entries = new Map<string, Response>();
|
||||
failToPut = false;
|
||||
|
||||
async match(request: RequestInfo | URL) {
|
||||
return this.entries.get(keyOf(request))?.clone();
|
||||
}
|
||||
|
||||
async put(request: RequestInfo | URL, response: Response) {
|
||||
if (this.failToPut) {
|
||||
throw new Error('quota');
|
||||
}
|
||||
this.entries.set(keyOf(request), response);
|
||||
}
|
||||
|
||||
async delete(request: RequestInfo | URL) {
|
||||
return this.entries.delete(keyOf(request));
|
||||
}
|
||||
|
||||
async keys() {
|
||||
return [...this.entries.keys()].map((url) => new Request(url));
|
||||
}
|
||||
}
|
||||
|
||||
const global = globalThis as unknown as { caches?: unknown; fetch: unknown };
|
||||
const original = { caches: global.caches, fetch: global.fetch };
|
||||
|
||||
describe('cachedFetch', () => {
|
||||
let cache: FakeCache;
|
||||
let fetched: string[];
|
||||
let names: string[];
|
||||
let options: { name: string; maxAge: number };
|
||||
let counter = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new FakeCache();
|
||||
fetched = [];
|
||||
names = [];
|
||||
// the caches are cleaned once per session and name: a new name for each test
|
||||
options = { name: `test-${counter++}`, maxAge: 60_000 };
|
||||
global.caches = {
|
||||
open: async (name: string) => {
|
||||
names.push(name);
|
||||
return cache;
|
||||
},
|
||||
};
|
||||
global.fetch = async (url: string) => {
|
||||
fetched.push(url);
|
||||
return new Response(JSON.stringify({ url }), {
|
||||
status: url.includes('missing') ? 404 : 200,
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.caches = original.caches;
|
||||
global.fetch = original.fetch;
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
it('asks the network once, then uses what it kept', async () => {
|
||||
const first = await cachedFetch('https://example.com/a', options);
|
||||
assert.deepEqual(await first.json(), { url: 'https://example.com/a' });
|
||||
const second = await cachedFetch('https://example.com/a', options);
|
||||
assert.deepEqual(await second.json(), { url: 'https://example.com/a' });
|
||||
assert.deepEqual(fetched, ['https://example.com/a']);
|
||||
assert.ok(names.every((name) => name === options.name));
|
||||
|
||||
await cachedFetch('https://example.com/b', options);
|
||||
assert.equal(fetched.length, 2);
|
||||
});
|
||||
|
||||
it('asks the network again for what is too old, and keeps the new response', async () => {
|
||||
mock.timers.enable({ apis: ['Date'], now: 1_000_000 });
|
||||
await cachedFetch('https://example.com/a', options);
|
||||
mock.timers.setTime(1_000_000 + options.maxAge - 1);
|
||||
await cachedFetch('https://example.com/a', options);
|
||||
assert.equal(fetched.length, 1);
|
||||
|
||||
mock.timers.setTime(1_000_000 + options.maxAge);
|
||||
await cachedFetch('https://example.com/a', options);
|
||||
assert.equal(fetched.length, 2);
|
||||
// the new one is the one that is kept: it is not too old a minute later
|
||||
mock.timers.setTime(1_000_000 + options.maxAge + 30_000);
|
||||
await cachedFetch('https://example.com/a', options);
|
||||
assert.equal(fetched.length, 2);
|
||||
});
|
||||
|
||||
it('does not keep what failed', async () => {
|
||||
const response = await cachedFetch('https://example.com/missing', options);
|
||||
assert.equal(response.status, 404);
|
||||
assert.equal(cache.entries.size, 0);
|
||||
await cachedFetch('https://example.com/missing', options);
|
||||
assert.equal(fetched.length, 2);
|
||||
});
|
||||
|
||||
it('answers even if the response cannot be kept', async () => {
|
||||
cache.failToPut = true;
|
||||
const response = await cachedFetch('https://example.com/a', options);
|
||||
assert.deepEqual(await response.json(), { url: 'https://example.com/a' });
|
||||
// let the failed put settle: it must not throw
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
it('works without the Cache API', async () => {
|
||||
global.caches = undefined;
|
||||
const response = await cachedFetch('https://example.com/a', options);
|
||||
assert.deepEqual(await response.json(), { url: 'https://example.com/a' });
|
||||
await cachedFetch('https://example.com/a', options);
|
||||
assert.equal(fetched.length, 2);
|
||||
});
|
||||
|
||||
it('works when the Cache API cannot be opened', async () => {
|
||||
global.caches = {
|
||||
open: async () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
const response = await cachedFetch('https://example.com/a', options);
|
||||
assert.equal(response.status, 200);
|
||||
});
|
||||
|
||||
it('cleans what is too old', async () => {
|
||||
mock.timers.enable({ apis: ['Date'], now: 5_000_000 });
|
||||
await cachedFetch('https://example.com/old', options);
|
||||
mock.timers.setTime(5_000_000 + options.maxAge / 2);
|
||||
await cachedFetch('https://example.com/recent', options);
|
||||
// a response that was kept by something else is not trusted
|
||||
await cache.put('https://example.com/foreign', new Response('{}'));
|
||||
|
||||
mock.timers.setTime(5_000_000 + options.maxAge + 1);
|
||||
await pruneCache(options);
|
||||
assert.deepEqual([...cache.entries.keys()], ['https://example.com/recent']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* `fetch` that keeps the responses in the Cache API, which the browser does whatever the headers
|
||||
* of the server are: the HTTP cache needs them to keep a response (and `cache: 'force-cache'`
|
||||
* can only use what it kept). A kept response is used until it is older than `maxAge`.
|
||||
*
|
||||
* It works without the Cache API (insecure pages, private windows, blocked storage): the
|
||||
* responses are then just not kept.
|
||||
*/
|
||||
|
||||
export type CachedFetchOptions = {
|
||||
/** Name of the cache the responses are kept in. */
|
||||
name: string;
|
||||
/** How long a kept response is used, in milliseconds. */
|
||||
maxAge: number;
|
||||
};
|
||||
|
||||
/** When a response was kept (ms since the epoch): the server's date can be wrong or missing. */
|
||||
const KEPT_AT = 'x-kept-at';
|
||||
|
||||
/** The caches that were cleaned of their old responses in this session. */
|
||||
const pruned = new Set<string>();
|
||||
|
||||
async function openCache(name: string): Promise<Cache | undefined> {
|
||||
try {
|
||||
return typeof caches === 'undefined' ? undefined : await caches.open(name);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function age(response: Response): number {
|
||||
const keptAt = Number(response.headers.get(KEPT_AT));
|
||||
return keptAt > 0 ? Date.now() - keptAt : Infinity;
|
||||
}
|
||||
|
||||
/** Deletes the responses that are too old to be used. */
|
||||
export async function pruneCache({ name, maxAge }: CachedFetchOptions) {
|
||||
const cache = await openCache(name);
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (const request of await cache.keys()) {
|
||||
const response = await cache.match(request);
|
||||
if (!response || !(age(response) < maxAge)) {
|
||||
await cache.delete(request);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// what is left is cleaned another time
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The response for `url`: the kept one if there is one that is not too old, otherwise the one of
|
||||
* the network, which is kept if it is successful.
|
||||
*/
|
||||
export async function cachedFetch(url: string, options: CachedFetchOptions): Promise<Response> {
|
||||
const cache = await openCache(options.name);
|
||||
if (cache && !pruned.has(options.name)) {
|
||||
pruned.add(options.name);
|
||||
void pruneCache(options);
|
||||
}
|
||||
|
||||
try {
|
||||
const kept = await cache?.match(url);
|
||||
if (kept && age(kept) < options.maxAge) {
|
||||
return kept;
|
||||
}
|
||||
} catch {
|
||||
// as if there was nothing
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok || !cache) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set(KEPT_AT, String(Date.now()));
|
||||
const stamped = new Response(await response.arrayBuffer(), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
// best effort: the storage can be full
|
||||
cache.put(url, stamped.clone()).catch(() => undefined);
|
||||
return stamped;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { legacyFileToGPX, LEGACY_IMPORTED_KEY } from '$lib/storage/legacy-import';
|
||||
|
||||
// A file as the former `gpx` library kept it.
|
||||
const point = (lat: number, lon: number, extra: Record<string, unknown> = {}) => ({
|
||||
attributes: { lat, lon },
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('legacyFileToGPX', () => {
|
||||
it('writes a GPX file with its metadata, waypoints and tracks', () => {
|
||||
const gpx = legacyFileToGPX({
|
||||
metadata: {
|
||||
name: 'Morning ride',
|
||||
desc: 'Around the lake',
|
||||
author: {
|
||||
name: 'someone',
|
||||
link: { attributes: { href: 'https://gpx.studio' }, text: 'site' },
|
||||
},
|
||||
link: { attributes: { href: 'https://example.com' } },
|
||||
time: '2024-05-01T08:00:00.000Z',
|
||||
},
|
||||
wpt: [
|
||||
point(50.1, 4.2, {
|
||||
ele: 100,
|
||||
name: 'Café',
|
||||
sym: 'Restaurant',
|
||||
link: { attributes: { href: 'https://cafe.example' }, text: 'menu' },
|
||||
}),
|
||||
],
|
||||
trk: [
|
||||
{
|
||||
name: 'Track 1',
|
||||
type: 'Cycling',
|
||||
trkseg: [
|
||||
{
|
||||
trkpt: [
|
||||
point(50, 4, { ele: 10, time: '2024-05-01T08:00:00.000Z' }),
|
||||
point(50.001, 4.001, { ele: 12 }),
|
||||
],
|
||||
},
|
||||
{ trkpt: [point(51, 5)] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(gpx.startsWith('<?xml version="1.0" encoding="UTF-8"?>'));
|
||||
assert.match(
|
||||
gpx,
|
||||
/<gpx version="1.1" creator="https:\/\/gpx.studio" xmlns="http:\/\/www.topografix.com\/GPX\/1\/1"/
|
||||
);
|
||||
assert.match(gpx, /<metadata><name>Morning ride<\/name><desc>Around the lake<\/desc>/);
|
||||
assert.match(
|
||||
gpx,
|
||||
/<author><name>someone<\/name><link href="https:\/\/gpx.studio"><text>site<\/text><\/link><\/author>/
|
||||
);
|
||||
assert.match(
|
||||
gpx,
|
||||
/<link href="https:\/\/example.com"><\/link><time>2024-05-01T08:00:00.000Z<\/time><\/metadata>/
|
||||
);
|
||||
assert.match(
|
||||
gpx,
|
||||
/<wpt lat="50.1" lon="4.2"><ele>100<\/ele><name>Café<\/name><link href="https:\/\/cafe.example"><text>menu<\/text><\/link><sym>Restaurant<\/sym><\/wpt>/
|
||||
);
|
||||
assert.match(gpx, /<trk><name>Track 1<\/name><type>Cycling<\/type><trkseg>/);
|
||||
assert.match(
|
||||
gpx,
|
||||
/<trkpt lat="50" lon="4"><ele>10<\/ele><time>2024-05-01T08:00:00.000Z<\/time><\/trkpt>/
|
||||
);
|
||||
assert.equal((gpx.match(/<trkseg>/g) ?? []).length, 2);
|
||||
assert.equal((gpx.match(/<trkpt /g) ?? []).length, 3);
|
||||
});
|
||||
|
||||
it('writes an empty file for an empty object', () => {
|
||||
const gpx = legacyFileToGPX({});
|
||||
assert.ok(gpx.includes('<metadata></metadata></gpx>'));
|
||||
assert.ok(!gpx.includes('<trk>') && !gpx.includes('<wpt'));
|
||||
});
|
||||
|
||||
it('escapes the text and the attributes', () => {
|
||||
const gpx = legacyFileToGPX({
|
||||
metadata: { name: 'Tom & Jerry <3', desc: 'a > b' },
|
||||
wpt: [
|
||||
point(1, 2, {
|
||||
name: '"quoted" & <tagged>',
|
||||
link: { attributes: { href: 'https://example.com/?a="1"&b=2' } },
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.ok(gpx.includes('<name>Tom & Jerry <3</name>'));
|
||||
assert.ok(gpx.includes('<desc>a > b</desc>'));
|
||||
assert.ok(gpx.includes('<name>"quoted" & <tagged></name>'));
|
||||
assert.ok(gpx.includes('href="https://example.com/?a="1"&b=2"'));
|
||||
});
|
||||
|
||||
it('leaves out what is missing or empty', () => {
|
||||
const gpx = legacyFileToGPX({
|
||||
metadata: { name: '', desc: null, time: 'not a date' },
|
||||
wpt: [point(1, 2, { name: '', ele: undefined, link: { attributes: {} } })],
|
||||
trk: [{ trkseg: [{ trkpt: [point(1, 2, { time: 'garbage' })] }] }],
|
||||
});
|
||||
assert.ok(!gpx.includes('<name>') && !gpx.includes('<desc>'));
|
||||
assert.ok(!gpx.includes('<time>'));
|
||||
assert.ok(!gpx.includes('<link'));
|
||||
assert.ok(gpx.includes('<wpt lat="1" lon="2"></wpt>'));
|
||||
assert.ok(gpx.includes('<trkpt lat="1" lon="2"></trkpt>'));
|
||||
// a track with a segment that has no points is still a segment
|
||||
assert.ok(legacyFileToGPX({ trk: [{ trkseg: [{}] }] }).includes('<trkseg></trkseg>'));
|
||||
});
|
||||
|
||||
it('writes the style of the tracks', () => {
|
||||
const gpx = legacyFileToGPX({
|
||||
trk: [
|
||||
{
|
||||
extensions: {
|
||||
'gpx_style:line': {
|
||||
'gpx_style:color': 'ff0000',
|
||||
'gpx_style:opacity': 0.5,
|
||||
'gpx_style:width': 6,
|
||||
},
|
||||
},
|
||||
trkseg: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(
|
||||
gpx.includes(
|
||||
'<extensions><gpx_style:line><gpx_style:color>ff0000</gpx_style:color><gpx_style:opacity>0.5</gpx_style:opacity><gpx_style:width>6</gpx_style:width></gpx_style:line></extensions>'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the extensions of the trackpoints', () => {
|
||||
const gpx = legacyFileToGPX({
|
||||
trk: [
|
||||
{
|
||||
trkseg: [
|
||||
{
|
||||
trkpt: [
|
||||
point(1, 2, {
|
||||
extensions: {
|
||||
'gpxtpx:TrackPointExtension': {
|
||||
'gpxtpx:atemp': 17.5,
|
||||
'gpxtpx:hr': 142,
|
||||
'gpxtpx:cad': 85,
|
||||
'gpxtpx:Extensions': {
|
||||
surface: 'asphalt',
|
||||
highway: 'cycleway',
|
||||
},
|
||||
},
|
||||
'gpxpx:PowerExtension': { 'gpxpx:PowerInWatts': 230 },
|
||||
},
|
||||
}),
|
||||
point(3, 4, {
|
||||
extensions: {
|
||||
'gpxtpx:TrackPointExtension': { 'gpxtpx:hr': 100 },
|
||||
},
|
||||
}),
|
||||
point(5, 6, {
|
||||
extensions: {
|
||||
'gpxpx:PowerExtension': { 'gpxpx:PowerInWatts': 0 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.ok(
|
||||
gpx.includes(
|
||||
'<extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17.5</gpxtpx:atemp><gpxtpx:hr>142</gpxtpx:hr><gpxtpx:cad>85</gpxtpx:cad><gpxtpx:Extensions><surface>asphalt</surface><highway>cycleway</highway></gpxtpx:Extensions></gpxtpx:TrackPointExtension><gpxpx:PowerExtension><gpxpx:PowerInWatts>230</gpxpx:PowerInWatts></gpxpx:PowerExtension></extensions>'
|
||||
)
|
||||
);
|
||||
assert.ok(
|
||||
gpx.includes(
|
||||
'<extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>100</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions>'
|
||||
)
|
||||
);
|
||||
// a power of 0 W is a value
|
||||
assert.ok(gpx.includes('<gpxpx:PowerInWatts>0</gpxpx:PowerInWatts>'));
|
||||
});
|
||||
|
||||
it('knows the setting that tells the import is done', () => {
|
||||
assert.equal(LEGACY_IMPORTED_KEY, 'legacyImported');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* One-time import of what the previous versions of the app kept in the browser: the files and the
|
||||
* settings of an IndexedDB database named `Database` (managed with Dexie, which is not needed to
|
||||
* read it). The files were stored as the objects of the former `gpx` library; they are written
|
||||
* back as GPX, which the engine loads. The old database is left as it was.
|
||||
*/
|
||||
|
||||
const LEGACY_DATABASE = 'Database';
|
||||
|
||||
/** Setting that tells that the import is done (or that there was nothing to import). */
|
||||
export const LEGACY_IMPORTED_KEY = 'legacyImported';
|
||||
|
||||
/** Settings of the old database that mean nothing anymore. */
|
||||
const DROPPED_SETTINGS = new Set(['patchIndex', 'fileOrder']);
|
||||
|
||||
type Json = any;
|
||||
|
||||
function request<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens the old database, `undefined` if there is none (and none is created). */
|
||||
async function openLegacyDatabase(): Promise<IDBDatabase | undefined> {
|
||||
if (typeof indexedDB === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof indexedDB.databases === 'function') {
|
||||
const databases = await indexedDB.databases();
|
||||
if (!databases.some((database) => database.name === LEGACY_DATABASE)) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const open = indexedDB.open(LEGACY_DATABASE);
|
||||
let created = false;
|
||||
open.onupgradeneeded = () => {
|
||||
// it did not exist: do not leave an empty database behind
|
||||
created = true;
|
||||
open.transaction?.abort();
|
||||
};
|
||||
open.onsuccess = () => resolve(open.result);
|
||||
open.onerror = () => {
|
||||
if (created) {
|
||||
indexedDB.deleteDatabase(LEGACY_DATABASE);
|
||||
}
|
||||
resolve(undefined);
|
||||
};
|
||||
open.onblocked = () => resolve(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
async function readAll(
|
||||
database: IDBDatabase,
|
||||
store: string
|
||||
): Promise<Map<string, Json> | undefined> {
|
||||
if (!database.objectStoreNames.contains(store)) {
|
||||
return undefined;
|
||||
}
|
||||
const objectStore = database.transaction(store, 'readonly').objectStore(store);
|
||||
const [keys, values] = await Promise.all([
|
||||
request(objectStore.getAllKeys()),
|
||||
request(objectStore.getAll()),
|
||||
]);
|
||||
return new Map(keys.map((key, i) => [String(key), values[i]]));
|
||||
}
|
||||
|
||||
// GPX, from the stored objects
|
||||
|
||||
function escape(text: unknown): string {
|
||||
return String(text).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
}
|
||||
|
||||
function element(name: string, value: unknown): string {
|
||||
return value === undefined || value === null || value === ''
|
||||
? ''
|
||||
: `<${name}>${escape(value)}</${name}>`;
|
||||
}
|
||||
|
||||
function timeElement(value: unknown): string {
|
||||
const date = value instanceof Date ? value : new Date(value as string);
|
||||
return isNaN(date.getTime()) ? '' : element('time', date.toISOString());
|
||||
}
|
||||
|
||||
function link(value: Json): string {
|
||||
const href = value?.attributes?.href;
|
||||
return href
|
||||
? `<link href="${escape(href).replaceAll('"', '"')}">${element('text', value.text)}</link>`
|
||||
: '';
|
||||
}
|
||||
|
||||
function coordinates(point: Json): string {
|
||||
const { lat, lon } = point.attributes ?? {};
|
||||
return `lat="${Number(lat)}" lon="${Number(lon)}"`;
|
||||
}
|
||||
|
||||
function trackpoint(point: Json): string {
|
||||
const extensions = point.extensions ?? {};
|
||||
const trackPointExtension = extensions['gpxtpx:TrackPointExtension'] ?? {};
|
||||
const osm = Object.entries(trackPointExtension['gpxtpx:Extensions'] ?? {})
|
||||
.map(([key, value]) => element(key, value))
|
||||
.join('');
|
||||
const inner = [
|
||||
element('gpxtpx:atemp', trackPointExtension['gpxtpx:atemp']),
|
||||
element('gpxtpx:hr', trackPointExtension['gpxtpx:hr']),
|
||||
element('gpxtpx:cad', trackPointExtension['gpxtpx:cad']),
|
||||
osm ? `<gpxtpx:Extensions>${osm}</gpxtpx:Extensions>` : '',
|
||||
].join('');
|
||||
const power = element(
|
||||
'gpxpx:PowerInWatts',
|
||||
extensions['gpxpx:PowerExtension']?.['gpxpx:PowerInWatts']
|
||||
);
|
||||
const extension =
|
||||
inner || power
|
||||
? `<extensions>${inner ? `<gpxtpx:TrackPointExtension>${inner}</gpxtpx:TrackPointExtension>` : ''}${power ? `<gpxpx:PowerExtension>${power}</gpxpx:PowerExtension>` : ''}</extensions>`
|
||||
: '';
|
||||
return `<trkpt ${coordinates(point)}>${element('ele', point.ele)}${point.time ? timeElement(point.time) : ''}${extension}</trkpt>`;
|
||||
}
|
||||
|
||||
function waypoint(point: Json): string {
|
||||
return `<wpt ${coordinates(point)}>${element('ele', point.ele)}${point.time ? timeElement(point.time) : ''}${element('name', point.name)}${element('cmt', point.cmt)}${element('desc', point.desc)}${link(point.link)}${element('sym', point.sym)}${element('type', point.type)}</wpt>`;
|
||||
}
|
||||
|
||||
function track(track: Json): string {
|
||||
const style = track.extensions?.['gpx_style:line'];
|
||||
const line = style
|
||||
? `<extensions><gpx_style:line>${element('gpx_style:color', style['gpx_style:color'])}${element('gpx_style:opacity', style['gpx_style:opacity'])}${element('gpx_style:width', style['gpx_style:width'])}</gpx_style:line></extensions>`
|
||||
: '';
|
||||
const segments = (track.trkseg ?? [])
|
||||
.map(
|
||||
(segment: Json) => `<trkseg>${(segment.trkpt ?? []).map(trackpoint).join('')}</trkseg>`
|
||||
)
|
||||
.join('');
|
||||
return `<trk>${element('name', track.name)}${element('cmt', track.cmt)}${element('desc', track.desc)}${element('src', track.src)}${link(track.link)}${element('type', track.type)}${line}${segments}</trk>`;
|
||||
}
|
||||
|
||||
/** A file as the old versions stored it, as GPX. */
|
||||
export function legacyFileToGPX(file: Json): string {
|
||||
const metadata = file.metadata ?? {};
|
||||
const author = metadata.author;
|
||||
const authorXml = author
|
||||
? `<author>${element('name', author.name)}${link(author.link)}</author>`
|
||||
: '';
|
||||
const metadataXml = `<metadata>${element('name', metadata.name)}${element('desc', metadata.desc)}${authorXml}${link(metadata.link)}${metadata.time ? timeElement(metadata.time) : ''}</metadata>`;
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="https://gpx.studio" xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxpx="http://www.garmin.com/xmlschemas/PowerExtension/v1" xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2">${metadataXml}${(file.wpt ?? []).map(waypoint).join('')}${(file.trk ?? []).map(track).join('')}</gpx>`;
|
||||
}
|
||||
|
||||
/** What the old database holds. */
|
||||
export type LegacyData = {
|
||||
/** The files as GPX, in the order they had. */
|
||||
files: { gpx: string; name: string }[];
|
||||
/** The settings, as JSON strings by key. */
|
||||
settings: Record<string, string>;
|
||||
};
|
||||
|
||||
/** Reads the old database, `undefined` if there is none. */
|
||||
export async function readLegacyData(): Promise<LegacyData | undefined> {
|
||||
const database = await openLegacyDatabase();
|
||||
if (!database) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const [files, settings] = await Promise.all([
|
||||
readAll(database, 'files'),
|
||||
readAll(database, 'settings'),
|
||||
]);
|
||||
if (!files && !settings) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// the order of the files was a setting; the ids (`gpx-<n>`) tell the order they were added in
|
||||
const byAge = [...(files?.keys() ?? [])].sort(
|
||||
(a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1])
|
||||
);
|
||||
const order: string[] = Array.isArray(settings?.get('fileOrder'))
|
||||
? settings!.get('fileOrder')
|
||||
: [];
|
||||
const ids = [
|
||||
...order.filter((id) => files?.has(id)),
|
||||
...byAge.filter((id) => !order.includes(id)),
|
||||
];
|
||||
|
||||
const result: LegacyData = { files: [], settings: {} };
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const file = files!.get(id);
|
||||
result.files.push({
|
||||
gpx: legacyFileToGPX(file),
|
||||
name: file?.metadata?.name || 'file',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Could not import the file ${id}`, error);
|
||||
}
|
||||
}
|
||||
for (const [key, value] of settings ?? []) {
|
||||
if (!DROPPED_SETTINGS.has(key) && value !== undefined) {
|
||||
try {
|
||||
result.settings[key] = JSON.stringify(value);
|
||||
} catch {
|
||||
// not something that can be kept
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user