refactoring

This commit is contained in:
vcoppe committed 2026-10-11 11:18:36 +02:00
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']);
});
});
+89
View File
@@ -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 &amp; Jerry &lt;3</name>'));
assert.ok(gpx.includes('<desc>a &gt; b</desc>'));
assert.ok(gpx.includes('<name>"quoted" &amp; &lt;tagged&gt;</name>'));
assert.ok(gpx.includes('href="https://example.com/?a=&quot;1&quot;&amp;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');
});
});
+211
View File
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
}
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('"', '&quot;')}">${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();
}
}