Files
TREK/client/src/components/Collections/collectionFile.ts
T
Maurice 80bb06c84f feat(collections): export and import a list as GPX
A list could leave the instance as a TREK file, which only another TREK
reads. Export now asks for a format, and GPX is the second one: the
places as waypoints, for OsmAnd, Organic Maps, a Garmin or gpx.studio.
Import takes a GPX in the same dialog as a list file. Asked for in
discussion #2301.

The GPX is written from the very list file the JSON export returns, so
what may leave the instance is still decided in one place. Name,
description and address, notes, website and category go into GPX's own
elements, where every app shows them. Labels, status, phone and the
rest travel in a TREK extension namespace, so a GPX made by TREK comes
back as the same list. A place without coordinates cannot be a
waypoint; it is left out and the export says how many were.

A GPX is read on the server, sent as text, with the XML library the
trip importer already uses. A document with a DOCTYPE is refused before
parsing, which closes both external entities and entity expansion. Size
is capped like the list file, and the element count is capped before a
tree is built. The reader only turns the GPX into a list file: the
browser previews it and imports it through the existing route, so one
contract, one transaction and one skipped counter cover both formats.
Waypoints and named route points become places; tracks and unnamed
route points are counted in the preview and not imported.

The two list-file routes get a body ceiling of twice the file limit.
The global 100 kB cap refused most real favourites files, and it
already refused list files well under the megabyte the contract allows.
2026-09-19 21:01:45 +02:00

142 lines
5.7 KiB
TypeScript

import {
COLLECTION_GPX_PROBLEMS,
collectionFileSchema,
MAX_COLLECTION_FILE_BYTES,
type CollectionFile,
type CollectionGpxProblem,
type CollectionGpxReadResult,
} from '@trek/shared'
/**
* The browser half of list export and import (#2198, #2301).
*
* Both files are built by the server, which is also where the decision about
* what may leave the instance lives (`collection-file.schema.ts`). This module
* only hands one to the browser as a download, and reads a chosen file back
* into something the import request will accept.
*
* A file that arrives here is a stranger's and is treated as one. A list file
* is read locally, never uploaded, parsed rather than evaluated, size-capped
* before parsing, and put through the shared contract so a field this build
* does not know is dropped instead of travelling on. A GPX is size-capped the
* same way and then sent as text to the server, which reads it with the XML
* library the trip importer already uses and hands back a list file: one side
* that parses XML, and it is the one that enforces the limits.
*/
export const COLLECTION_FILE_EXTENSION = '.trekcollection.json'
export const COLLECTION_GPX_EXTENSION = '.gpx'
/** The two ways a list can leave: whole, for another TREK, or as waypoints for everything else. */
export type CollectionExportFormat = 'trek' | 'gpx'
/** A file name from a list name: lowercase, ascii-ish, short. */
export function collectionFileName(name: string, extension = COLLECTION_FILE_EXTENSION): string {
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
return `${slug || 'collection'}${extension}`
}
function download(content: string, type: string, fileName: string): void {
const url = URL.createObjectURL(new Blob([content], { type }))
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
// Revoked on the next tick: revoking synchronously can cancel the download
// in some browsers before it has started reading the blob.
setTimeout(() => URL.revokeObjectURL(url), 0)
}
/** Hand the file to the browser as a download. */
export function downloadCollectionFile(file: CollectionFile): void {
download(JSON.stringify(file, null, 2), 'application/json', collectionFileName(file.name))
}
/** Hand a list's GPX to the browser as a download, named like its list file would be. */
export function downloadCollectionGpx(listName: string, gpx: string): void {
download(gpx, 'application/gpx+xml', collectionFileName(listName, COLLECTION_GPX_EXTENSION))
}
export type CollectionFileError = 'not-a-collection' | CollectionGpxProblem
/** What a GPX held that did not become a place. */
export interface GpxLeftovers {
/** Waypoints without usable coordinates. */
skipped: number
/** Track points and unnamed route points: lines, not places. */
trackPoints: number
}
/**
* Exactly one of `file` and `error` is set; `gpx` only when the file was one.
*
* Nullable fields rather than a discriminated union on an `ok` flag: the
* client compiles with `strict: false`, and without `strictNullChecks` a
* `{ok: true} | {ok: false}` union does not narrow, so every read of the
* failure branch would need a cast.
*/
export interface ParsedCollectionFile {
file: CollectionFile | null
error: CollectionFileError | null
gpx?: GpxLeftovers | null
}
/** Reads a GPX document into a list file. The server does that, so the caller supplies it. */
export type GpxReader = (gpx: string, fileName: string) => Promise<CollectionGpxReadResult>
/** A list file is JSON and JSON never opens with `<`, so this is all the sniffing a GPX needs. */
function looksLikeXml(text: string): boolean {
const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
return withoutBom.trimStart().startsWith('<')
}
/** The reason the server gave for refusing a GPX, when it gave one this build knows. */
function gpxProblemOf(err: unknown): CollectionGpxProblem | null {
const code: unknown = (err as { response?: { data?: { code?: unknown } } } | null)?.response?.data?.code
return COLLECTION_GPX_PROBLEMS.find(problem => problem === code) ?? null
}
async function readGpx(text: string, fileName: string, reader: GpxReader): Promise<ParsedCollectionFile> {
try {
const result = await reader(text, fileName)
return { file: result.file, error: null, gpx: { skipped: result.skipped, trackPoints: result.track_points } }
} catch (err) {
const problem = gpxProblemOf(err)
// No code means the failure was not the file's (offline, a 500), and that
// is for the dialog to report as it reports a failed import.
if (!problem) throw err
return { file: null, error: problem, gpx: null }
}
}
/**
* Read a chosen file into a list file, or say why it is not one.
*
* The failures are kept apart because they are different things a person can
* do something about: a file too big to be one of ours, a file that is not
* readable at all, a JSON that is not a TREK list, an XML that is not a GPX,
* and a GPX with more places than a list may hold.
*/
export async function readCollectionFile(file: File, readGpxFile: GpxReader): Promise<ParsedCollectionFile> {
if (file.size > MAX_COLLECTION_FILE_BYTES) return { file: null, error: 'too-large' }
let text: string
try {
text = await file.text()
} catch {
return { file: null, error: 'unreadable' }
}
if (looksLikeXml(text)) return readGpx(text, file.name ?? '', readGpxFile)
let raw: unknown
try {
raw = JSON.parse(text)
} catch {
return { file: null, error: 'unreadable' }
}
const parsed = collectionFileSchema.safeParse(raw)
return parsed.success
? { file: parsed.data, error: null }
: { file: null, error: 'not-a-collection' }
}