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.
This commit is contained in:
Maurice
2026-09-19 21:01:45 +02:00
parent bb6003684c
commit 80bb06c84f
57 changed files with 2506 additions and 109 deletions
+2
View File
@@ -26,3 +26,5 @@
*.eot binary
*.pdf binary
*.zip binary
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
server/tests/fixtures/gpx/bom-crlf.gpx -text
+20 -1
View File
@@ -1,4 +1,4 @@
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-034
//
// The Collections addon wrapper is thin, but every method encodes a URL, a verb and a
// request-body shape that the server contract depends on. These tests drive each method
@@ -342,4 +342,23 @@ describe('collectionsApi', () => {
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] })
expect(res.changed).toBe(1)
})
it('FE-API-COLLECTIONS-033: exportGpx() reads the GPX of a list by id', async () => {
server.use(http.get(`${BASE}/:id/export/gpx`, record({ name: 'Tokyo', gpx: '<gpx/>', waypoints: 0, omitted: 2 })))
const res = await collectionsApi.exportGpx(1)
expect(requestUrl).toContain(`${BASE}/1/export/gpx`)
expect(res).toEqual({ name: 'Tokyo', gpx: '<gpx/>', waypoints: 0, omitted: 2 })
})
it('FE-API-COLLECTIONS-034: readGpx() posts the document and its file name', async () => {
const file = { format: 'trek.collection', version: 1, name: 'Tokyo', places: [] }
server.use(http.post(`${BASE}/gpx/read`, record({ file, skipped: 0, track_points: 12 })))
const res = await collectionsApi.readGpx({ gpx: '<gpx/>', file_name: 'tokyo.gpx' })
expect(requestBody).toEqual({ gpx: '<gpx/>', file_name: 'tokyo.gpx' })
expect(res).toEqual({ file, skipped: 0, track_points: 12 })
})
})
+9
View File
@@ -13,6 +13,9 @@ import type {
CollectionFile,
CollectionImportRequest,
CollectionImportResult,
CollectionGpxExport,
CollectionGpxReadRequest,
CollectionGpxReadResult,
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
@@ -62,6 +65,12 @@ export const collectionsApi = {
ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data),
importFile: (body: CollectionImportRequest): Promise<CollectionImportResult> =>
ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data),
// The same list as GPX (#2301). A GPX is read by the server into a list file,
// which then goes through importFile above like any other.
exportGpx: (id: number): Promise<CollectionGpxExport> =>
ax.get(`${base}/${id}/export/gpx`).then((r: AxiosResponse) => r.data),
readGpx: (body: CollectionGpxReadRequest): Promise<CollectionGpxReadResult> =>
ax.post(`${base}/gpx/read`, body satisfies CollectionGpxReadRequest).then((r: AxiosResponse) => r.data),
// Both answer with the bare collection, not a { collection } envelope — the
// controller returns the service's `Collection` straight through, and the e2e
// suite pins that shape. Declaring the envelope made createCollection() resolve
@@ -0,0 +1,120 @@
// FE-COMP-COLEXPORT-001 to FE-COMP-COLEXPORT-008
import React from 'react';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useTranslation } from '../../i18n/TranslationContext';
import CollectionExportMenu from './CollectionExportMenu';
type MenuProps = Omit<React.ComponentProps<typeof CollectionExportMenu>, 't'>;
function Harness(props: MenuProps): React.ReactElement {
const { t } = useTranslation();
return (
<>
<CollectionExportMenu {...props} t={t} />
<button type="button">Somewhere else</button>
</>
);
}
function renderMenu(overrides: Partial<MenuProps> = {}) {
const props: MenuProps = { onExport: vi.fn(), ...overrides };
render(<Harness {...props} />);
return props;
}
const trigger = () => screen.getByRole('button', { name: 'Export' });
const items = () => screen.getAllByRole('menuitem');
describe('CollectionExportMenu (#2301)', () => {
it('FE-COMP-COLEXPORT-001: is a closed menu button until pressed', () => {
renderMenu();
expect(trigger()).toHaveAttribute('aria-haspopup', 'menu');
expect(trigger()).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('menu')).toBeNull();
});
it('FE-COMP-COLEXPORT-002: offers both formats, each with its extension and what it is for', async () => {
renderMenu();
await userEvent.click(trigger());
const menu = screen.getByRole('menu', { name: 'Download this list as a file' });
expect(trigger()).toHaveAttribute('aria-expanded', 'true');
expect(trigger()).toHaveAttribute('aria-controls', menu.id);
expect(items().map(item => item.textContent)).toEqual([
'TREK list.trekcollection.jsonFor another TREK, with labels and status',
'GPX.gpxWaypoints for OsmAnd, Organic Maps, Garmin and other map apps',
]);
});
it('FE-COMP-COLEXPORT-003: exports the picked format, closes and gives focus back', async () => {
const props = renderMenu();
await userEvent.click(trigger());
await userEvent.click(screen.getByRole('menuitem', { name: /GPX/ }));
expect(props.onExport).toHaveBeenCalledWith('gpx');
expect(screen.queryByRole('menu')).toBeNull();
expect(trigger()).toHaveFocus();
});
it('FE-COMP-COLEXPORT-004: moves focus into the menu and walks it with the arrow keys, Home and End', async () => {
renderMenu();
await userEvent.click(trigger());
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{ArrowDown}');
expect(items()[1]).toHaveFocus();
// Past the end wraps round, as it does in every menu.
await userEvent.keyboard('{ArrowDown}');
expect(items()[0]).toHaveFocus();
await userEvent.keyboard('{ArrowUp}');
expect(items()[1]).toHaveFocus();
await userEvent.keyboard('{Home}');
expect(items()[0]).toHaveFocus();
await userEvent.keyboard('{End}');
expect(items()[1]).toHaveFocus();
});
it('FE-COMP-COLEXPORT-005: opens from the keyboard and picks with Enter', async () => {
const props = renderMenu();
trigger().focus();
await userEvent.keyboard('{ArrowDown}');
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{Enter}');
expect(props.onExport).toHaveBeenCalledWith('trek');
});
it('FE-COMP-COLEXPORT-006: closes on Escape without exporting, and gives focus back', async () => {
const props = renderMenu();
await userEvent.click(trigger());
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('menu')).toBeNull();
expect(trigger()).toHaveFocus();
expect(props.onExport).not.toHaveBeenCalled();
});
it('FE-COMP-COLEXPORT-007: closes on a click anywhere else, and on Tab', async () => {
renderMenu();
await userEvent.click(trigger());
await userEvent.click(screen.getByRole('button', { name: 'Somewhere else' }));
expect(screen.queryByRole('menu')).toBeNull();
await userEvent.click(trigger());
await waitFor(() => expect(items()[0]).toHaveFocus());
await userEvent.keyboard('{Tab}');
expect(screen.queryByRole('menu')).toBeNull();
});
it('FE-COMP-COLEXPORT-008: cannot be opened while an export is still running', async () => {
renderMenu({ exporting: true });
expect(trigger()).toBeDisabled();
await userEvent.click(trigger());
expect(screen.queryByRole('menu')).toBeNull();
});
});
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Download, FileJson, Loader2, Route, type LucideIcon } from 'lucide-react'
import type { TranslationFn } from '../../types'
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
import { COLLECTION_FILE_EXTENSION, COLLECTION_GPX_EXTENSION, type CollectionExportFormat } from './collectionFile'
interface CollectionExportMenuProps {
onExport: (format: CollectionExportFormat) => void
exporting?: boolean
t: TranslationFn
}
interface FormatOption {
format: CollectionExportFormat
icon: LucideIcon
label: string
extension: string
hint: string
}
/** Wide enough for the longest hint on one or two lines at the default text size. */
const MENU_WIDTH = 300
/**
* Export in the list hero: a menu of the two formats a list can leave in (#2301).
*
* The TREK file carries everything and is for another TREK; GPX carries the
* places as waypoints and is for every other map app. Choosing is the whole
* interaction, so it is a menu rather than a dialog.
*
* Portalled, because the hero clips its overflow, and placed against the
* trigger's right edge, where the button sits. A menu in the WAI-ARIA sense:
* focus moves into it on open, the arrow keys, Home and End walk it, Escape
* closes it and gives focus back to the button, and Tab leaves it closed.
*/
export default function CollectionExportMenu({ onExport, exporting, t }: CollectionExportMenuProps): React.ReactElement {
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuId = useId()
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 150, offset: 6 })
// Focusable only once positioned: until then the menu is hidden, and a hidden
// element refuses focus.
const placed = open && anchored !== null
const options: FormatOption[] = [
{
format: 'trek', icon: FileJson, label: t('collections.file.formatTrek'),
extension: COLLECTION_FILE_EXTENSION, hint: t('collections.file.formatTrekHint'),
},
{
format: 'gpx', icon: Route, label: 'GPX',
extension: COLLECTION_GPX_EXTENSION, hint: t('collections.file.formatGpxHint'),
},
]
const items = () => Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])
const close = useCallback((refocus: boolean) => {
setOpen(false)
if (refocus) triggerRef.current?.focus()
}, [])
useEffect(() => {
if (placed) menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
}, [placed])
useEffect(() => {
if (!open) return
const onPointerDown = (e: MouseEvent) => {
const target = e.target as Node
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return
close(false)
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [open, close])
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const all = items()
const at = all.indexOf(document.activeElement as HTMLElement)
const focus = (index: number) => {
e.preventDefault()
all[(index + all.length) % all.length]?.focus()
}
if (e.key === 'ArrowDown') focus(at + 1)
else if (e.key === 'ArrowUp') focus(at - 1)
else if (e.key === 'Home') focus(0)
else if (e.key === 'End') focus(all.length - 1)
else if (e.key === 'Escape') {
e.preventDefault()
close(true)
} else if (e.key === 'Tab') close(false)
}
const pick = (format: CollectionExportFormat) => {
close(true)
onExport(format)
}
// Pinned by its right edge, under the button, so it grows leftwards into the hero.
const right = anchored ? Math.max(8, window.innerWidth - anchored.left - anchored.width) : 8
return (
<>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(o => !o)}
onKeyDown={e => {
if (e.key === 'ArrowDown' && !open) {
e.preventDefault()
setOpen(true)
}
}}
disabled={exporting}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
aria-label={t('collections.file.export')}
title={t('collections.file.exportTitle')}
className="col-glass-btn"
>
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} />}
<span className="txt">{t('collections.file.export')}</span>
</button>
{open && createPortal(
<div
ref={menuRef}
id={menuId}
role="menu"
aria-label={t('collections.file.exportTitle')}
onKeyDown={onMenuKeyDown}
className="trek-popover-enter fixed z-[var(--z-toast)] p-1.5 rounded-xl border border-edge bg-surface-card shadow-dropdown"
style={{
...(anchored?.flipped ? { bottom: anchored.bottom } : { top: anchored?.top ?? 0 }),
right,
width: MENU_WIDTH,
maxWidth: 'calc(100vw - 16px)',
visibility: anchored ? 'visible' : 'hidden',
}}
>
{options.map(option => (
<button
key={option.format}
type="button"
role="menuitem"
tabIndex={-1}
onClick={() => pick(option.format)}
className="w-full flex items-start gap-3 px-3 py-2.5 rounded-lg text-left transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
>
<option.icon size={16} className="mt-0.5 shrink-0 text-content-faint" aria-hidden />
<span className="flex-1 min-w-0">
<span className="flex items-baseline justify-between gap-3">
<span className="text-body font-semibold text-content">{option.label}</span>
<span className="text-caption text-content-faint">{option.extension}</span>
</span>
<span className="block text-caption text-content-muted">{option.hint}</span>
</span>
</button>
))}
</div>,
document.body,
)}
</>
)
}
@@ -172,8 +172,10 @@ describe('CollectionHero', () => {
const all = [...document.querySelectorAll('.col-hero-actions button')];
expect(all).toEqual(actions);
// Export asks which format first (#2301).
await userEvent.click(actions[1]);
expect(onExport).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole('menuitem', { name: /TREK list/ }));
expect(onExport).toHaveBeenCalledWith('trek');
});
it('FE-COMP-COLHERO-013: has no Export button where there is no list to export', () => {
@@ -186,8 +188,9 @@ describe('CollectionHero', () => {
renderHero({ canEdit: false, canShare: false, isOwner: false, onExport });
await userEvent.click(screen.getByRole('button', { name: 'Export' }));
await userEvent.click(screen.getByRole('menuitem', { name: /GPX/ }));
expect(onExport).toHaveBeenCalledTimes(1);
expect(onExport).toHaveBeenCalledWith('gpx');
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
});
@@ -1,8 +1,10 @@
import React from 'react'
import { avatarSrc } from '../../utils/avatarSrc'
import { Share2, Users, Link2, Pencil, Download, Loader2 } from 'lucide-react'
import { Share2, Users, Link2, Pencil } from 'lucide-react'
import type { CollectionMember, CollectionLink } from '@trek/shared'
import type { TranslationFn } from '../../types'
import CollectionExportMenu from './CollectionExportMenu'
import type { CollectionExportFormat } from './collectionFile'
const AV_COLORS = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#3b82f6', '#ef4444', '#22c55e']
@@ -27,7 +29,7 @@ interface CollectionHeroProps {
shareMemberCount: number
onShare: () => void
/** Absent on the "All saved" pseudo-list, which is a view rather than a list. */
onExport?: () => void
onExport?: (format: CollectionExportFormat) => void
exporting?: boolean
t: TranslationFn
}
@@ -98,19 +100,7 @@ export default function CollectionHero({
<span className="txt">{t('common.edit')}</span>
</button>
)}
{onExport && (
<button
type="button"
onClick={onExport}
disabled={exporting}
aria-label={t('collections.file.export')}
title={t('collections.file.exportTitle')}
className="col-glass-btn"
>
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} />}
<span className="txt">{t('collections.file.export')}</span>
</button>
)}
{onExport && <CollectionExportMenu onExport={onExport} exporting={exporting} t={t} />}
{canShare && (
<button
type="button"
@@ -1,9 +1,10 @@
// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-010
// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-016
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import type { CollectionFile } from '@trek/shared';
import { useTranslation } from '../../i18n/TranslationContext';
import ImportCollectionModal from './ImportCollectionModal';
import type { GpxReader } from './collectionFile';
// The modal takes `t` as a prop, so this harness forwards the real English
// translator and the assertions read as the strings a person sees.
@@ -23,6 +24,7 @@ const listFile: CollectionFile = {
function renderModal(overrides: Partial<React.ComponentProps<typeof ImportCollectionModal>> = {}) {
const props = {
onImport: vi.fn().mockResolvedValue(undefined),
onReadGpx: vi.fn().mockRejectedValue(new Error('no GPX expected here')),
onClose: vi.fn(),
...overrides,
};
@@ -33,7 +35,7 @@ function renderModal(overrides: Partial<React.ComponentProps<typeof ImportCollec
/** Put a file into the hidden input the way a file picker would. */
async function choose(content: string, name = 'list.trekcollection.json') {
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File([content], name, { type: 'application/json' });
const file = new File([content], name, { type: name.endsWith('.gpx') ? 'application/gpx+xml' : 'application/json' });
// jsdom's File has no usable text(); the component awaits it.
Object.defineProperty(file, 'text', { value: () => Promise.resolve(content) });
await userEvent.upload(input, file);
@@ -45,7 +47,6 @@ describe('ImportCollectionModal (#2198)', () => {
it('FE-COMP-COLLIMPORT-001: opens asking for a file, with Import not yet possible', () => {
renderModal();
expect(screen.getByText('Choose a list file')).toBeInTheDocument();
expect(screen.getByText('.trekcollection.json')).toBeInTheDocument();
expect(importButton()).toBeDisabled();
});
@@ -142,3 +143,93 @@ describe('ImportCollectionModal (#2198)', () => {
expect(props.onImport).not.toHaveBeenCalled();
});
});
describe('ImportCollectionModal with a GPX (#2301)', () => {
const gpx = '<?xml version="1.0"?><gpx version="1.1"><wpt lat="1" lon="2"><name>Pena Palace</name></wpt></gpx>';
const gpxFile: CollectionFile = {
format: 'trek.collection', version: 1, name: 'Sintra loop',
places: [{ name: 'Pena Palace' }, { name: 'Moorish Castle' }],
} as CollectionFile;
const refusal = (code: string) => Object.assign(new Error('Request failed'), { response: { status: 400, data: { error: 'no', code } } });
it('FE-COMP-COLLIMPORT-011: says which files it takes', () => {
renderModal();
expect(screen.getByText('.trekcollection.json · .gpx')).toBeInTheDocument();
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input.accept).toContain('.gpx');
});
it('FE-COMP-COLLIMPORT-012: shows what a GPX holds, and what of it will not become a place', async () => {
const onReadGpx = vi.fn().mockResolvedValue({ file: gpxFile, skipped: 2, track_points: 8 });
const props = renderModal({ onReadGpx });
await choose(gpx, 'sintra.gpx');
await waitFor(() => expect(screen.getByDisplayValue('Sintra loop')).toBeInTheDocument());
expect(onReadGpx).toHaveBeenCalledWith(gpx, 'sintra.gpx');
expect(screen.getByText('2 places')).toBeInTheDocument();
expect(screen.getByText('2 waypoints without usable coordinates are left out.')).toBeInTheDocument();
expect(screen.getByText('This file also has 8 track points. Tracks are not imported, only waypoints.')).toBeInTheDocument();
// The note about ratings and members is about TREK files; a GPX never had any.
expect(screen.queryByText(/Ratings, members/)).toBeNull();
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(gpxFile, undefined));
});
it('FE-COMP-COLLIMPORT-013: imports a GPX under a name the person typed', async () => {
const props = renderModal({ onReadGpx: vi.fn().mockResolvedValue({ file: gpxFile, skipped: 0, track_points: 0 }) });
await choose(gpx, 'sintra.gpx');
await waitFor(() => expect(screen.getByDisplayValue('Sintra loop')).toBeInTheDocument());
await userEvent.clear(screen.getByRole('textbox'));
await userEvent.type(screen.getByRole('textbox'), 'Sintra with the kids');
await userEvent.click(importButton());
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(gpxFile, 'Sintra with the kids'));
expect(screen.queryByText(/track points/)).toBeNull();
});
it('FE-COMP-COLLIMPORT-014: will not make an empty list of a GPX that only has a track', async () => {
const trackOnly = { ...gpxFile, places: [] };
renderModal({ onReadGpx: vi.fn().mockResolvedValue({ file: trackOnly, skipped: 0, track_points: 120 }) });
await choose(gpx, 'track.gpx');
await waitFor(() => expect(screen.getByText('This GPX file has no waypoints, so there is nothing to import.')).toBeInTheDocument());
expect(screen.getByText('This file also has 120 track points. Tracks are not imported, only waypoints.')).toBeInTheDocument();
expect(importButton()).toBeDisabled();
});
it('FE-COMP-COLLIMPORT-015: says in its own words why a GPX was refused', async () => {
const onReadGpx = vi.fn()
.mockRejectedValueOnce(refusal('not-gpx'))
.mockRejectedValueOnce(refusal('too-many-places'))
.mockRejectedValueOnce(refusal('unreadable'));
renderModal({ onReadGpx });
await choose(gpx, 'a.gpx');
await waitFor(() => expect(screen.getByText('That is not a GPX file.')).toBeInTheDocument());
await choose(gpx, 'b.gpx');
await waitFor(() => expect(screen.getByText(
'That file has more than 1000 places. Split it and import the parts one at a time.',
)).toBeInTheDocument());
await choose(gpx, 'c.gpx');
await waitFor(() => expect(screen.getByText('That file could not be read.')).toBeInTheDocument());
expect(importButton()).toBeDisabled();
});
it('FE-COMP-COLLIMPORT-016: says it is reading while the server reads, and why it could not', async () => {
let fail: ((err: unknown) => void) | null = null;
const onReadGpx = vi.fn<GpxReader>(() => new Promise((_, reject) => { fail = reject; }));
renderModal({ onReadGpx });
await choose(gpx, 'slow.gpx');
await waitFor(() => expect(screen.getByText('Reading the file…')).toBeInTheDocument());
fail!(Object.assign(new Error('x'), { response: { status: 503, data: { error: 'The server is busy' } } }));
await waitFor(() => expect(screen.getByText('The server is busy')).toBeInTheDocument());
expect(screen.getByText('Choose a list file')).toBeInTheDocument();
});
});
@@ -1,19 +1,57 @@
import React, { useRef, useState } from 'react'
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle } from 'lucide-react'
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle, Route, Info } from 'lucide-react'
import Modal from '../shared/Modal'
import type { CollectionFile } from '@trek/shared'
import { MAX_COLLECTION_FILE_PLACES, type CollectionFile } from '@trek/shared'
import type { TranslationFn } from '../../types'
import { getApiErrorMessage } from '../../types'
import { readCollectionFile, type CollectionFileError, COLLECTION_FILE_EXTENSION } from './collectionFile'
import {
readCollectionFile,
type CollectionFileError,
type GpxLeftovers,
type GpxReader,
COLLECTION_FILE_EXTENSION,
COLLECTION_GPX_EXTENSION,
} from './collectionFile'
interface ImportCollectionModalProps {
onImport: (file: CollectionFile, name?: string) => Promise<void>
/** Reads a GPX into a list file (#2301); the server does the parsing. */
onReadGpx: GpxReader
onClose: () => void
t: TranslationFn
}
/** Keyed by the error type, so a new way for a file to fail does not build without its words. */
const ERROR_KEYS: Record<CollectionFileError, string> = {
'too-large': 'collections.file.errorTooLarge',
unreadable: 'collections.file.errorUnreadable',
'not-a-collection': 'collections.file.errorNotACollection',
'not-gpx': 'collections.file.errorNotGpx',
'too-many-places': 'collections.file.errorTooManyPlaces',
}
/** What a GPX held besides its places, said before anything is imported. */
function GpxNotes({ leftovers, placeCount, t }: { leftovers: GpxLeftovers; placeCount: number; t: TranslationFn }) {
const notes = [
placeCount === 0 && t('collections.file.gpxEmpty'),
leftovers.skipped > 0 && t('collections.file.gpxSkipped', { count: leftovers.skipped }),
leftovers.trackPoints > 0 && t('collections.file.gpxTrack', { count: leftovers.trackPoints }),
].filter(Boolean)
if (notes.length === 0) return null
return (
<ul className="flex flex-col gap-1">
{notes.map(note => (
<li key={note as string} className="flex items-start gap-2 text-[12px] text-content-muted">
<Info size={13} className="shrink-0 mt-0.5 text-content-faint" />
<span>{note}</span>
</li>
))}
</ul>
)
}
/**
* Read a list file and make a list of it (#2198).
* Read a list file or a GPX and make a list of it (#2198, #2301).
*
* Two steps on purpose. A file from somebody else is an unknown quantity, so
* it is read and shown first — how many places, which labels, what the list is
@@ -22,27 +60,41 @@ interface ImportCollectionModalProps {
* "Lisbon (from Ana)" on the way in, and renaming it afterwards means finding
* the list editor.
*
* The file never leaves the browser as a file: it is parsed here, and what
* goes to the server is the parsed list, through the same contract the server
* validates against.
* A list file never leaves the browser as a file: it is parsed here. A GPX is
* read by the server into the same kind of list file, and from then on the two
* are one path: what goes to the import is a list file, through the same
* contract the server validates against.
*/
export default function ImportCollectionModal({ onImport, onClose, t }: ImportCollectionModalProps): React.ReactElement {
export default function ImportCollectionModal({ onImport, onReadGpx, onClose, t }: ImportCollectionModalProps): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<CollectionFile | null>(null)
const [leftovers, setLeftovers] = useState<GpxLeftovers | null>(null)
const [name, setName] = useState('')
const [error, setError] = useState<CollectionFileError | 'failed' | null>(null)
const [failedMessage, setFailedMessage] = useState<string | null>(null)
const [reading, setReading] = useState(false)
const [busy, setBusy] = useState(false)
const [dragging, setDragging] = useState(false)
const take = async (chosen: File | undefined) => {
if (!chosen) return
if (!chosen || reading) return
setError(null)
setFailedMessage(null)
const result = await readCollectionFile(chosen)
setReading(true)
try {
const result = await readCollectionFile(chosen, onReadGpx)
setFile(result.file)
setLeftovers(result.gpx ?? null)
setError(result.error)
if (result.file) setName(result.file.name)
} catch (err) {
setFile(null)
setLeftovers(null)
setError('failed')
setFailedMessage(getApiErrorMessage(err, t('common.error')))
} finally {
setReading(false)
}
}
const onDrop = (e: React.DragEvent) => {
@@ -69,10 +121,10 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
const errorText = error === 'failed'
? (failedMessage ?? t('common.error'))
: error === 'too-large' ? t('collections.file.errorTooLarge')
: error === 'unreadable' ? t('collections.file.errorUnreadable')
: error === 'not-a-collection' ? t('collections.file.errorNotACollection')
: null
: error ? t(ERROR_KEYS[error], { count: MAX_COLLECTION_FILE_PLACES }) : null
// A GPX of nothing but a track would make an empty list, which is never what was meant.
const nothingToImport = !!leftovers && !!file && file.places.length === 0
return (
<Modal
@@ -88,7 +140,7 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
<button
type="button"
onClick={submit}
disabled={!file || busy || !name.trim()}
disabled={!file || busy || !name.trim() || nothingToImport}
className="px-4 py-2 rounded-lg text-[13px] font-semibold bg-accent text-accent-text disabled:opacity-50 transition-opacity inline-flex items-center gap-2"
>
{busy && <Loader2 size={14} className="animate-spin" />}
@@ -101,7 +153,7 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
<input
ref={inputRef}
type="file"
accept=".json,application/json"
accept=".json,application/json,.gpx,application/gpx+xml"
className="hidden"
onChange={e => { void take(e.target.files?.[0]); e.target.value = '' }}
/>
@@ -113,19 +165,23 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
disabled={reading}
aria-busy={reading}
className={`flex flex-col items-center justify-center gap-2 px-4 py-10 rounded-xl border border-dashed transition-colors ${
dragging ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
}`}
>
<Upload size={22} className="text-content-faint" />
<span className="text-[13px] font-medium text-content">{t('collections.file.choose')}</span>
<span className="text-[11.5px] text-content-faint">{COLLECTION_FILE_EXTENSION}</span>
{reading ? <Loader2 size={22} className="text-content-faint animate-spin" /> : <Upload size={22} className="text-content-faint" />}
<span className="text-[13px] font-medium text-content">
{reading ? t('collections.file.reading') : t('collections.file.choose')}
</span>
<span className="text-[11.5px] text-content-faint">{COLLECTION_FILE_EXTENSION} · {COLLECTION_GPX_EXTENSION}</span>
</button>
) : (
<>
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card">
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: file.color || '#6366f1' }}>
<FileJson size={15} />
{leftovers ? <Route size={15} /> : <FileJson size={15} />}
</span>
<span className="flex-1 min-w-0">
<span className="block text-[13px] font-semibold text-content truncate">{file.name}</span>
@@ -145,6 +201,8 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
<p className="text-[12px] text-content-muted whitespace-pre-wrap">{file.description}</p>
)}
{leftovers && <GpxNotes leftovers={leftovers} placeCount={file.places.length} t={t} />}
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span>
<input
@@ -155,7 +213,7 @@ export default function ImportCollectionModal({ onImport, onClose, t }: ImportCo
/>
</label>
<p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>
{!leftovers && <p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>}
</>
)}
@@ -1,6 +1,14 @@
// FE-COLL-FILE-001 to FE-COLL-FILE-012
// FE-COLL-FILE-001 to FE-COLL-FILE-020
import { describe, it, expect, vi, afterEach } from 'vitest'
import { collectionFileName, downloadCollectionFile, readCollectionFile, COLLECTION_FILE_EXTENSION } from './collectionFile'
import {
collectionFileName,
downloadCollectionFile,
downloadCollectionGpx,
readCollectionFile,
COLLECTION_FILE_EXTENSION,
COLLECTION_GPX_EXTENSION,
type GpxReader,
} from './collectionFile'
import type { CollectionFile } from '@trek/shared'
const file = (over: Partial<CollectionFile> = {}): CollectionFile => ({
@@ -8,10 +16,16 @@ const file = (over: Partial<CollectionFile> = {}): CollectionFile => ({
} as CollectionFile)
/** A File whose text() the jsdom build does not provide on its own. */
function asFile(content: string, size = content.length): File {
return { size, text: () => Promise.resolve(content) } as unknown as File
function asFile(content: string, size = content.length, name = 'list.trekcollection.json'): File {
return { size, name, text: () => Promise.resolve(content) } as unknown as File
}
/** A reader that must not be reached: a list file is never sent to the server. */
const noGpx: GpxReader = () => { throw new Error('a list file went to the GPX reader') }
/** A rejection the way axios delivers the server's refusal. */
const refused = (code: string) => Object.assign(new Error('Request failed'), { response: { status: 400, data: { error: 'no', code } } })
describe('collectionFileName', () => {
it('FE-COLL-FILE-001: turns a list name into a short ascii file name', () => {
expect(collectionFileName('Lisbon')).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
@@ -23,69 +37,94 @@ describe('collectionFileName', () => {
const name = collectionFileName('a'.repeat(200))
expect(name).toBe('a'.repeat(40) + COLLECTION_FILE_EXTENSION)
})
it('FE-COLL-FILE-013: names a GPX the same way, with its own extension', () => {
expect(collectionFileName('Wochenende in Rom!', COLLECTION_GPX_EXTENSION)).toBe('wochenende-in-rom.gpx')
})
})
describe('downloadCollectionFile', () => {
afterEach(() => { vi.restoreAllMocks() })
describe('downloads', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('FE-COLL-FILE-003: hands the browser a named JSON blob and releases it afterwards', () => {
/** Captures what would be downloaded, and under which name. */
function captureDownload() {
vi.useFakeTimers()
const revoke = vi.fn()
const created: Blob[] = []
const names: string[] = []
vi.stubGlobal('URL', {
createObjectURL: (b: Blob) => { created.push(b); return 'blob:x' },
revokeObjectURL: revoke,
})
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
expect(this.download).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
expect(this.href).toContain('blob:')
names.push(this.download)
})
return { revoke, created, names, click }
}
it('FE-COLL-FILE-003: hands the browser a named JSON blob and releases it afterwards', () => {
const { revoke, created, names, click } = captureDownload()
downloadCollectionFile(file())
expect(click).toHaveBeenCalledTimes(1)
expect(names).toEqual(['lisbon' + COLLECTION_FILE_EXTENSION])
expect(created[0].type).toBe('application/json')
expect(document.querySelector('a')).toBeNull()
expect(revoke).not.toHaveBeenCalled()
vi.runAllTimers()
expect(revoke).toHaveBeenCalledWith('blob:x')
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('FE-COLL-FILE-014: hands the browser a GPX under the list name', async () => {
const { created, names } = captureDownload()
downloadCollectionGpx('Lisbon', '<gpx/>')
expect(names).toEqual(['lisbon.gpx'])
expect(created[0].type).toBe('application/gpx+xml')
expect(await created[0].text()).toBe('<gpx/>')
})
})
describe('readCollectionFile', () => {
it('FE-COLL-FILE-004: reads one of ours', async () => {
const result = await readCollectionFile(asFile(JSON.stringify(file())))
const result = await readCollectionFile(asFile(JSON.stringify(file())), noGpx)
expect(result.error).toBeNull()
expect(result.file?.name).toBe('Lisbon')
expect(result.gpx).toBeUndefined()
})
it('FE-COLL-FILE-005: refuses a file too large to be one, without reading it', async () => {
const text = vi.fn()
const huge = { size: 1024 * 1024 + 1, text } as unknown as File
const result = await readCollectionFile(huge)
const result = await readCollectionFile(huge, noGpx)
expect(result).toEqual({ file: null, error: 'too-large' })
expect(text).not.toHaveBeenCalled()
})
it('FE-COLL-FILE-006: says a file is unreadable when it is not JSON', async () => {
expect(await readCollectionFile(asFile('not json at all'))).toEqual({ file: null, error: 'unreadable' })
expect(await readCollectionFile(asFile('not json at all'), noGpx)).toEqual({ file: null, error: 'unreadable' })
})
it('FE-COLL-FILE-007: says a JSON that is not a list is not a list', async () => {
for (const content of ['{}', '[]', 'null', '"a string"', JSON.stringify({ format: 'something.else', version: 1, name: 'x', places: [] })]) {
expect(await readCollectionFile(asFile(content)), content).toEqual({ file: null, error: 'not-a-collection' })
expect(await readCollectionFile(asFile(content), noGpx), content).toEqual({ file: null, error: 'not-a-collection' })
}
})
it('FE-COLL-FILE-008: refuses a list with no name, since the list would have none', async () => {
const result = await readCollectionFile(asFile(JSON.stringify({ format: 'trek.collection', version: 1, name: '', places: [] })))
const result = await readCollectionFile(asFile(JSON.stringify({ format: 'trek.collection', version: 1, name: '', places: [] })), noGpx)
expect(result.error).toBe('not-a-collection')
})
it('FE-COLL-FILE-009: reads an empty list, which is a list somebody has not filled yet', async () => {
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: [] }))))
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: [] }))), noGpx)
expect(result.error).toBeNull()
expect(result.file?.places).toEqual([])
})
@@ -94,20 +133,66 @@ describe('readCollectionFile', () => {
// The envelope counts places; each one is validated where it is written.
const result = await readCollectionFile(asFile(JSON.stringify(file({
places: [{ name: 'Fine' }, { nonsense: true }] as never,
}))))
}))), noGpx)
expect(result.error).toBeNull()
expect(result.file?.places).toHaveLength(2)
})
it('FE-COLL-FILE-011: refuses a file carrying more places than the contract allows', async () => {
const tooMany = Array.from({ length: 1001 }, (_, i) => ({ name: `Place ${i}` }))
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: tooMany as never }))))
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: tooMany as never }))), noGpx)
expect(result.error).toBe('not-a-collection')
})
it('FE-COLL-FILE-012: reads a file from a later version, since its additions are optional', async () => {
const result = await readCollectionFile(asFile(JSON.stringify({ ...file(), version: 99, somethingNew: 'x' })))
const result = await readCollectionFile(asFile(JSON.stringify({ ...file(), version: 99, somethingNew: 'x' })), noGpx)
expect(result.error).toBeNull()
expect(result.file?.version).toBe(99)
})
})
describe('readCollectionFile with a GPX (#2301)', () => {
const gpx = '<?xml version="1.0"?><gpx version="1.1"><wpt lat="1" lon="2"><name>A</name></wpt></gpx>'
it('FE-COLL-FILE-015: sends a GPX to the reader with its file name and keeps what did not become a place', async () => {
const read = vi.fn<GpxReader>().mockResolvedValue({ file: file({ name: 'Favourites' }), skipped: 2, track_points: 40 })
const result = await readCollectionFile(asFile(gpx, gpx.length, 'favourites.gpx'), read)
expect(read).toHaveBeenCalledWith(gpx, 'favourites.gpx')
expect(result).toEqual({ file: expect.objectContaining({ name: 'Favourites' }), error: null, gpx: { skipped: 2, trackPoints: 40 } })
})
it('FE-COLL-FILE-016: knows a GPX by what it is, not by what it is called', async () => {
const read = vi.fn<GpxReader>().mockResolvedValue({ file: file(), skipped: 0, track_points: 0 })
// A byte-order mark and a blank line before the declaration, under a .xml name.
await readCollectionFile(asFile(String.fromCharCode(0xfeff) + '\n ' + gpx, 100, 'export.xml'), read)
expect(read).toHaveBeenCalledTimes(1)
})
it('FE-COLL-FILE-017: holds a GPX to the same size limit, before reading it', async () => {
const read = vi.fn<GpxReader>()
expect(await readCollectionFile(asFile(gpx, 1024 * 1024 + 1, 'big.gpx'), read)).toEqual({ file: null, error: 'too-large' })
expect(read).not.toHaveBeenCalled()
})
it('FE-COLL-FILE-018: turns the reason the server refused a GPX into the dialog\'s own', async () => {
for (const code of ['unreadable', 'not-gpx', 'too-many-places', 'too-large'] as const) {
const read = vi.fn<GpxReader>().mockRejectedValue(refused(code))
expect(await readCollectionFile(asFile(gpx), read), code).toEqual({ file: null, error: code, gpx: null })
}
})
it('FE-COLL-FILE-019: passes on a failure that was not about the file', async () => {
const offline = vi.fn<GpxReader>().mockRejectedValue(new Error('Network Error'))
await expect(readCollectionFile(asFile(gpx), offline)).rejects.toThrow('Network Error')
const unknownCode = vi.fn<GpxReader>().mockRejectedValue(refused('something-new'))
await expect(readCollectionFile(asFile(gpx), unknownCode)).rejects.toThrow('Request failed')
})
it('FE-COLL-FILE-020: says a file is unreadable when its text cannot be had at all', async () => {
const broken = { size: 10, name: 'x.gpx', text: () => Promise.reject(new Error('gone')) } as unknown as File
expect(await readCollectionFile(broken, noGpx)).toEqual({ file: null, error: 'unreadable' })
})
})
@@ -1,34 +1,46 @@
import { collectionFileSchema, MAX_COLLECTION_FILE_BYTES, type CollectionFile } from '@trek/shared'
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).
* The browser half of list export and import (#2198, #2301).
*
* The file itself is 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 the JSON to the browser as a download, and reads a chosen
* file back into something the import request will accept.
* 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 JSON and is treated as one: 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 to the server.
* 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): string {
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'}${COLLECTION_FILE_EXTENSION}`
return `${slug || 'collection'}${extension}`
}
/** Hand the file to the browser as a download. */
export function downloadCollectionFile(file: CollectionFile): void {
const json = JSON.stringify(file, null, 2)
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }))
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 = collectionFileName(file.name)
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
@@ -37,12 +49,30 @@ export function downloadCollectionFile(file: CollectionFile): void {
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export type CollectionFileError = 'too-large' | 'unreadable' | 'not-a-collection'
/** 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 the two is set.
* Exactly one of `file` and `error` is set; `gpx` only when the file was one.
*
* Two nullable fields rather than a discriminated union on an `ok` flag: the
* 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.
@@ -50,20 +80,57 @@ export type CollectionFileError = 'too-large' | 'unreadable' | 'not-a-collection
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 three failures are kept apart because they are three different things a
* person can do something about: a file too big to be one of ours, a file that
* is not JSON at all, and a JSON that is not a TREK list.
* 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): Promise<ParsedCollectionFile> {
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(await file.text())
raw = JSON.parse(text)
} catch {
return { file: null, error: 'unreadable' }
}
+1
View File
@@ -349,6 +349,7 @@ function CollectionsPageDesktop(): React.ReactElement {
{c.showImportFile && (
<ImportCollectionModal
onImport={c.handleImportFile}
onReadGpx={c.handleReadGpx}
onClose={() => c.setShowImportFile(false)}
t={t}
/>
@@ -1,4 +1,4 @@
// FE-PAGE-COLL-001 to FE-PAGE-COLL-076
// FE-PAGE-COLL-001 to FE-PAGE-COLL-081
// The Collections page hook. The collection store is replaced by a fixture so
// every handler/branch can be driven directly; the categories request goes
// through MSW, and the websocket module is already mocked in tests/setup.ts.
@@ -31,8 +31,9 @@ vi.mock('../../store/collectionStore', () => ({
// here the question is only whether the hook hands it the file it fetched.
vi.mock('../../components/Collections/collectionFile', () => ({
downloadCollectionFile: vi.fn(),
downloadCollectionGpx: vi.fn(),
}))
import { downloadCollectionFile } from '../../components/Collections/collectionFile'
import { downloadCollectionFile, downloadCollectionGpx } from '../../components/Collections/collectionFile'
// ── Fixtures ─────────────────────────────────────────────────────────────────
function makeStore() {
@@ -153,6 +154,7 @@ beforeEach(() => {
navigate.mockClear()
addToast.mockClear()
vi.mocked(downloadCollectionFile).mockClear()
vi.mocked(downloadCollectionGpx).mockClear()
vi.mocked(addListener).mockClear()
vi.mocked(removeListener).mockClear()
installMatchMedia()
@@ -1074,3 +1076,75 @@ describe('useCollections — export and import as a file', () => {
expect(seen[0]).toEqual({ file: listFile, name: 'Lisbon (from Ana)' })
})
})
// ── The same list as GPX (#2301) ─────────────────────────────────────────────
describe('useCollections: GPX (#2301)', () => {
const exportGpx = (body: Record<string, unknown>) =>
server.use(http.get('/api/addons/collections/:id/export/gpx', () => HttpResponse.json(body)))
it('FE-PAGE-COLL-077: downloads the GPX under the list name, and not the list file', async () => {
exportGpx({ name: 'Lisbon', gpx: '<gpx/>', waypoints: 2, omitted: 0 })
store.activeId = 11
const { result } = await setup()
await act(async () => { await result.current.handleExportList('gpx') })
expect(downloadCollectionGpx).toHaveBeenCalledWith('Lisbon', '<gpx/>')
expect(downloadCollectionFile).not.toHaveBeenCalled()
expect(addToast).not.toHaveBeenCalled()
expect(result.current.exporting).toBe(false)
})
it('FE-PAGE-COLL-078: says how many places had no coordinates and stayed out', async () => {
exportGpx({ name: 'Lisbon', gpx: '<gpx/>', waypoints: 2, omitted: 3 })
store.activeId = 11
const { result } = await setup()
await act(async () => { await result.current.handleExportList('gpx') })
expect(downloadCollectionGpx).toHaveBeenCalled()
expect(addToast).toHaveBeenCalledWith('3 places have no coordinates and were left out of the GPX file', 'info', undefined)
})
it('FE-PAGE-COLL-079: hands over no empty GPX when no place has coordinates', async () => {
exportGpx({ name: 'Lisbon', gpx: '<gpx/>', waypoints: 0, omitted: 4 })
store.activeId = 11
const { result } = await setup()
await act(async () => { await result.current.handleExportList('gpx') })
expect(downloadCollectionGpx).not.toHaveBeenCalled()
expect(addToast).toHaveBeenCalledWith(
'None of these places has coordinates, so there is nothing to put in a GPX file.', 'warning', undefined,
)
expect(result.current.exporting).toBe(false)
})
it('FE-PAGE-COLL-080: a failed GPX export says so', async () => {
server.use(http.get('/api/addons/collections/:id/export/gpx', () => HttpResponse.json({ error: 'Nope' }, { status: 500 })))
store.activeId = 11
const { result } = await setup()
await act(async () => { await result.current.handleExportList('gpx') })
expect(downloadCollectionGpx).not.toHaveBeenCalled()
expect(addToast).toHaveBeenCalledWith('Nope', 'error', undefined)
})
it('FE-PAGE-COLL-081: reads a GPX through the server and hands the list file back to the dialog', async () => {
const seen: unknown[] = []
const read = { file: { format: 'trek.collection', version: 1, name: 'Sintra', places: [] }, skipped: 1, track_points: 9 }
server.use(http.post('/api/addons/collections/gpx/read', async ({ request }) => {
seen.push(await request.json())
return HttpResponse.json(read)
}))
const { result } = await setup()
let answer: unknown
await act(async () => { answer = await result.current.handleReadGpx('<gpx/>', 'sintra.gpx') })
expect(seen[0]).toEqual({ gpx: '<gpx/>', file_name: 'sintra.gpx' })
expect(answer).toEqual(read)
})
})
+22 -4
View File
@@ -7,7 +7,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../../components/shared/Toast'
import { collectionsApi } from '../../api/collections'
import { downloadCollectionFile } from '../../components/Collections/collectionFile'
import { downloadCollectionFile, downloadCollectionGpx, type CollectionExportFormat } from '../../components/Collections/collectionFile'
import { getApiErrorMessage } from '../../types'
import { addListener, removeListener } from '../../api/websocket'
import { useCollectionStore, ALL_SAVED } from '../../store/collectionStore'
@@ -206,16 +206,28 @@ export function useCollections() {
const handlePlaceAdded = useCallback(() => { refreshActive() }, [refreshActive])
/**
* Download the active list as a file.
* Download the active list as a file, in the format picked from the menu.
*
* The file is fetched rather than built from what this page holds: the page
* has the places for display, the server decides what may leave the instance.
* A GPX holds only places with coordinates, so the rest are counted and the
* count is said; a list with none at all gets no empty file (#2301).
*/
const handleExportList = useCallback(async () => {
const handleExportList = useCallback(async (format: CollectionExportFormat = 'trek') => {
if (typeof activeId !== 'number') return
setExporting(true)
try {
if (format === 'trek') {
downloadCollectionFile(await collectionsApi.exportFile(activeId))
return
}
const result = await collectionsApi.exportGpx(activeId)
if (result.waypoints === 0) {
toast.warning(t('collections.file.gpxNothing'))
return
}
downloadCollectionGpx(result.name, result.gpx)
if (result.omitted > 0) toast.info(t('collections.file.gpxOmitted', { count: result.omitted }))
} catch (err) {
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
@@ -223,6 +235,12 @@ export function useCollections() {
}
}, [activeId, toast, t])
/** A GPX read into a list file for the import dialog to show. The server does the parsing. */
const handleReadGpx = useCallback(
(gpx: string, fileName: string) => collectionsApi.readGpx({ gpx, file_name: fileName }),
[],
)
/**
* Read a chosen file and create the list it describes.
*
@@ -449,7 +467,7 @@ export function useCollections() {
showAddPlace, setShowAddPlace, handlePlaceAdded,
showImport, setShowImport,
exporting, handleExportList,
showImportFile, setShowImportFile, handleImportFile,
showImportFile, setShowImportFile, handleImportFile, handleReadGpx,
confirmDeleteList, setConfirmDeleteList,
mobileRailOpen, setMobileRailOpen,
showShare, setShowShare, handleAfterLeave,
+13 -2
View File
@@ -18,6 +18,7 @@ import { validateRouteGuards } from './nest/common/validate-route-guards';
import { validateManagedRoutes } from './nest/common/validate-managed-routes';
import { TrekWsAdapter } from './nest/realtime/trek-ws.adapter';
import { StorageService } from './nest/storage/storage.service';
import { MAX_COLLECTION_FILE_BYTES } from '@trek/shared';
/**
* Builds the unified TREK NestJS application that serves the ENTIRE surface — the
@@ -142,18 +143,28 @@ export async function buildApp(): Promise<INestApplication> {
* but a save that quietly fails and an editor that says "not saved" without
* saying why.
*
* So the book route, and only the book route, is measured against the size a
* book can actually be. Everything else keeps the tighter limit.
* So the book route is measured against the size a book can actually be.
*
* A list file is the same story at a smaller scale. Its contract allows a
* megabyte and the import posts it whole, yet a list of a few hundred places
* with notes is past a hundred kilobytes already, and so is the favourites
* GPX of anybody who uses OsmAnd (#2301). The two routes that carry one are
* measured against that megabyte, doubled for the JSON escaping of a
* document full of quotes. Everything else keeps the tighter limit.
*/
const bookBody = express.json({ limit: '8mb', verify: rawBodyKeeper });
const listFileBody = express.json({ limit: MAX_COLLECTION_FILE_BYTES * 2, verify: rawBodyKeeper });
const json = express.json({ limit: '100kb', verify: rawBodyKeeper });
const urlencoded = express.urlencoded({ limit: '100kb', extended: true, verify: rawBodyKeeper });
const isMcp = (req: Request) => req.path === '/mcp' || req.path === '/mcp/';
const isBookWrite = (req: Request) =>
req.method === 'PUT' && /^\/api\/journeys\/\d+\/book$/.test(req.path);
const listFilePaths = new Set(['/api/addons/collections/import', '/api/addons/collections/gpx/read']);
const isListFile = (req: Request) => req.method === 'POST' && listFilePaths.has(req.path);
instance.use(function jsonParser(req: Request, res: Response, next: NextFunction) {
if (isBookWrite(req)) return bookBody(req, res, next);
if (isListFile(req)) return listFileBody(req, res, next);
return isMcp(req) ? next() : json(req, res, next);
});
instance.use(function urlencodedParser(req: Request, res: Response, next: NextFunction) {
@@ -0,0 +1,483 @@
import { XMLParser } from 'fast-xml-parser';
import type { ZodType } from 'zod';
import {
COLLECTION_FILE_FORMAT,
COLLECTION_FILE_VERSION,
COLLECTION_GPX_NAMESPACE,
COLLECTION_GPX_PLACE_FIELDS,
MAX_COLLECTION_FILE_BYTES,
MAX_COLLECTION_FILE_LABELS,
MAX_COLLECTION_FILE_PLACES,
collectionFileLabelSchema,
collectionFilePlaceSchema,
collectionFileSchema,
collectionLinkSchema,
type CollectionFile,
type CollectionFileLabel,
type CollectionFilePlace,
type CollectionGpxExport,
type CollectionGpxProblem,
type CollectionGpxReadResult,
type CollectionLink,
} from '@trek/shared';
import { coord, gpxBuilder } from '../places/gpx-export.helpers';
/**
* A list as GPX and back (#2301).
*
* Both halves work on the list file from `collection-file.schema.ts`, never on
* rows: the writer is handed the file the JSON export returns, so it cannot
* carry anything the file does not, and the reader produces a file that is
* then imported like any other. The only thing this module decides is how the
* fields of a place map onto a waypoint, and that table lives in the contract
* (`COLLECTION_GPX_PLACE_FIELDS`).
*
* Same library as the trip importer and exporter in places/, with a reader of
* its own: the trip parser turns `<name>007</name>` into the number 7 and does
* not decode character references, which is fine for track geometry and wrong
* for the names and notes a list is made of.
*/
/** A list file as the exporter builds it, its places already in the file's shape. */
export type ExportedCollectionFile = Omit<CollectionFile, 'places'> & { places: CollectionFilePlace[] };
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
/** The prefix this writer binds; the reader looks the namespace up instead. */
const TREK = 'trek';
/** OsmAnd writes its favourites with the address in its own namespace. */
const OSMAND_NAMESPACES = ['https://osmand.net', 'http://osmand.net'];
const HTTP_URL = /^https?:\/\//i;
/** The extension fields that hold one value each; labels and links repeat. */
const SCALAR_EXTENSIONS = (Object.keys(COLLECTION_GPX_PLACE_FIELDS) as (keyof CollectionFilePlace)[]).filter(
field => COLLECTION_GPX_PLACE_FIELDS[field] === 'extension' && field !== 'labels' && field !== 'links',
);
// ---------------------------------------------------------------------------
// Writing
// ---------------------------------------------------------------------------
/**
* Text XML 1.0 can hold. The builder escapes markup but passes through the
* control characters the spec forbids outright, and one of those in a place
* name is enough for a strict reader to refuse the whole file.
*/
function xmlText(value: string): string {
return value.replace(/[\p{Cc}\p{Cs}\uFFFE\uFFFF]/gu, ch => (ch === '\t' || ch === '\n' || ch === '\r' ? ch : ''));
}
/** A value as element text, or nothing, since an empty element is noise to every reader. */
function textOf(value: string | number | null | undefined): string | undefined {
if (value === null || value === undefined) return undefined;
return xmlText(String(value)).trim() || undefined;
}
/**
* `<desc>` is what other apps show under the name, so it gets the place's
* description and, on a line of its own after it, the address: GPX has no
* element for an address, and without it a waypoint in OsmAnd is a pin with a
* name. The address also travels in the extension, which is what TREK reads
* back; the reader strips this copy again (see `withoutAddress`).
*/
function describe(place: CollectionFilePlace): string | undefined {
return textOf([place.description?.trim(), place.address?.trim()].filter(Boolean).join('\n\n'));
}
function placeExtensions(place: CollectionFilePlace): Record<string, unknown> | undefined {
const ext: Record<string, unknown> = {};
for (const field of SCALAR_EXTENSIONS) {
const value: unknown = place[field];
if (typeof value === 'string' || typeof value === 'number') ext[`${TREK}:${field}`] = textOf(value);
}
const labels = (place.labels ?? []).map(textOf).filter(Boolean);
if (labels.length) ext[`${TREK}:label`] = labels;
const links = (place.links ?? []).filter(link => HTTP_URL.test(link.url));
if (links.length) {
ext[`${TREK}:link`] = links.map(link => ({ '@_href': xmlText(link.url), '#text': textOf(link.label) }));
}
return Object.keys(ext).length ? ext : undefined;
}
/** Element order is the GPX 1.1 schema's: name, cmt, desc, link, type, extensions. */
function waypoint(place: CollectionFilePlace, lat: number, lng: number): Record<string, unknown> {
const website = place.website && HTTP_URL.test(place.website) ? xmlText(place.website) : undefined;
return {
'@_lat': coord(lat),
'@_lon': coord(lng),
name: xmlText(place.name),
cmt: textOf(place.notes),
desc: describe(place),
link: website ? { '@_href': website } : undefined,
type: textOf(place.category),
extensions: placeExtensions(place),
};
}
function metadata(file: ExportedCollectionFile): Record<string, unknown> {
const ext: Record<string, unknown> = {};
if (file.color) ext[`${TREK}:color`] = textOf(file.color);
if (file.icon) ext[`${TREK}:icon`] = textOf(file.icon);
if (file.labels?.length) {
ext[`${TREK}:label`] = file.labels.map(label => ({
...(label.color ? { '@_color': xmlText(label.color) } : {}),
'#text': xmlText(label.name),
}));
}
return {
name: xmlText(file.name),
desc: textOf(file.description),
time: file.exported_at,
extensions: Object.keys(ext).length ? ext : undefined,
};
}
/**
* The list as a GPX 1.1 document: one waypoint per place that has
* coordinates. A place without them cannot be a waypoint, so it is left out
* and counted, and the caller says how many.
*/
export function collectionFileToGpx(file: ExportedCollectionFile): CollectionGpxExport {
const wpt: Record<string, unknown>[] = [];
let omitted = 0;
for (const place of file.places) {
const { lat, lng } = place;
if (typeof lat !== 'number' || typeof lng !== 'number' || !Number.isFinite(lat) || !Number.isFinite(lng)) {
omitted += 1;
continue;
}
wpt.push(waypoint(place, lat, lng));
}
const gpx: string = gpxBuilder.build({
'?xml': { '@_version': '1.0', '@_encoding': 'UTF-8' },
gpx: {
'@_version': '1.1',
'@_creator': 'TREK',
'@_xmlns': GPX_NAMESPACE,
[`@_xmlns:${TREK}`]: COLLECTION_GPX_NAMESPACE,
'@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
'@_xsi:schemaLocation': `${GPX_NAMESPACE} ${GPX_NAMESPACE}/gpx.xsd`,
metadata: metadata(file),
...(wpt.length ? { wpt } : {}),
},
});
return { name: file.name, gpx, waypoints: wpt.length, omitted };
}
// ---------------------------------------------------------------------------
// Reading
// ---------------------------------------------------------------------------
/** Why a document was refused, carried to the controller as the response's `code`. */
export class CollectionGpxError extends Error {
constructor(
readonly code: CollectionGpxProblem,
message: string,
) {
super(message);
this.name = 'CollectionGpxError';
}
}
/**
* The most elements a document may open before it is parsed at all.
*
* The size cap already bounds this, but loosely: a megabyte of `<a/>` is a
* quarter of a million nodes for the parser to build. A real GPX of that size
* is a recorded track at three or four elements a point, around thirty
* thousand, so this sits well clear of every genuine file.
*/
export const MAX_GPX_ELEMENTS = 100_000;
/** Elements that may repeat, so they always parse as arrays. Matched on the local name. */
const REPEATED = new Set(['wpt', 'rte', 'rtept', 'trk', 'trkseg', 'trkpt', 'link', 'label']);
const localName = (name: string): string => name.slice(name.indexOf(':') + 1);
/**
* Text stays text (`parseTagValue: false`), so a waypoint called 007 keeps its
* zeros. `htmlEntities` is on for the numeric character references it also
* unlocks (`&#233;`), which this library otherwise leaves undecoded. Entities
* declared in a DOCTYPE never reach it: a document carrying one is refused
* before parsing.
*/
const reader = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
parseTagValue: false,
parseAttributeValue: false,
htmlEntities: true,
isArray: (name, _path, _leaf, isAttribute) => !isAttribute && REPEATED.has(localName(name)),
});
type XmlNode = Record<string, unknown>;
function isNode(value: unknown): value is XmlNode {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** An element as an object; `<wpt/>` parses as an empty string, which has no fields either. */
function node(value: unknown): XmlNode {
return isNode(value) ? value : {};
}
/** An empty list reads as no list, the way the file itself leaves one out. */
function someOf<T>(items: T[]): T[] | undefined {
return items.length ? items : undefined;
}
function asArray(value: unknown): unknown[] {
if (Array.isArray(value)) return value;
return value === undefined || value === null ? [] : [value];
}
/** An element's text: plain, or beside attributes as `#text`. A repeated element counts once. */
function text(value: unknown): string | null {
if (Array.isArray(value)) return text(value[0]);
if (typeof value === 'string') return value.trim() || null;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (isNode(value)) return text(value['#text']);
return null;
}
function attr(value: unknown, name: string): string | null {
const raw = node(value)[`@_${name}`];
return typeof raw === 'string' ? raw.trim() || null : null;
}
/** A coordinate attribute as a number, or null. The range is the contract's to check. */
function coordinate(value: unknown): number | null {
if (typeof value !== 'string' || value.trim() === '') return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
/** Cut to the contract's length without leaving half of a surrogate pair behind. */
function clip(value: string, max: number | null): string {
if (max === null || value.length <= max) return value;
const cut = value.slice(0, max);
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
}
/** The prefix a document binds to one of these namespaces on its root, if any. */
function prefixFor(root: XmlNode, namespaces: readonly string[]): string | null {
for (const [key, value] of Object.entries(root)) {
if (key.startsWith('@_xmlns:') && typeof value === 'string' && namespaces.includes(value.trim())) {
return key.slice('@_xmlns:'.length);
}
}
return null;
}
/**
* `raw` in the shape `schema` accepts, or null.
*
* A field the schema refuses is dropped and the rest kept, which is the file
* contract's own rule: one unreadable field costs that field. Only a refused
* `required` field costs the whole thing, because without it nothing is left.
* The schema is the one place the limits live, so none are repeated here.
*/
function fit<T>(schema: ZodType<T>, raw: Record<string, unknown>, required: readonly string[]): T | null {
const given = Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== null && v !== undefined));
const first = schema.safeParse(given);
if (first.success) return first.data;
const refused = new Set(first.error.issues.map(issue => String(issue.path[0])));
if (required.some(field => refused.has(field))) return null;
const second = schema.safeParse(Object.fromEntries(Object.entries(given).filter(([key]) => !refused.has(key))));
return second.success ? second.data : null;
}
const LABEL_NAME = collectionFilePlaceSchema.shape.labels.unwrap().element;
/** GPX 1.1 `<link href>`, or GPX 1.0's `<url>` beside `<urlname>`. */
function gpxLinks(el: XmlNode): CollectionLink[] {
const found = asArray(el.link).map(link => ({ url: attr(link, 'href'), label: text(node(link).text) }));
if (found.length === 0 && text(el.url)) found.push({ url: text(el.url), label: text(el.urlname) });
return found
.map(link => fit(collectionLinkSchema, link, ['url']))
.filter((link): link is CollectionLink => link !== null);
}
/**
* The description as the writer had it: when a TREK file put the address
* under it as a courtesy to other apps, the address comes back off.
*/
function withoutAddress(desc: string | null, address: string | null): string | null {
if (!desc || !address) return desc;
if (desc === address) return null;
const suffix = `\n\n${address}`;
return desc.endsWith(suffix) ? desc.slice(0, -suffix.length).trim() || null : desc;
}
/** The prefixes a document bound to the namespaces this reader understands. */
interface Prefixes {
trek: string | null;
osmand: string | null;
}
/**
* One waypoint (or named route point) as a place, or null when it cannot be one.
*
* Coordinates are the one thing a waypoint must have. A missing name is given
* one, `Waypoint N` after its position in the file, which is what the trip
* importer calls it too: an unnamed pin is still a place somebody marked, and
* the number is how they find it again in the app that made the file.
*/
function readPlace(value: unknown, fallbackName: string | null, prefixes: Prefixes): CollectionFilePlace | null {
const el = node(value);
const lat = coordinate(el['@_lat']);
const lng = coordinate(el['@_lon']);
const name = text(el.name) ?? fallbackName;
if (lat === null || lng === null || !name) return null;
const ext = node(el.extensions);
const own = (field: string): unknown => (prefixes.trek ? ext[`${prefixes.trek}:${field}`] : undefined);
const address = text(own('address')) ?? (prefixes.osmand ? text(ext[`${prefixes.osmand}:address`]) : null);
const desc = text(el.desc);
const cmt = text(el.cmt);
const [website, ...moreLinks] = gpxLinks(el);
const ownLinks = asArray(own('link'))
.map(link => fit(collectionLinkSchema, { url: attr(link, 'href'), label: text(link) }, ['url']))
.filter((link): link is CollectionLink => link !== null);
const raw: Record<string, unknown> = {
name: clip(name, collectionFilePlaceSchema.shape.name.maxLength),
lat,
lng,
description: prefixes.trek ? withoutAddress(desc, address) : desc,
// Garmin writes the same text into both; once is enough.
notes: !prefixes.trek && cmt === desc ? null : cmt,
website: website?.url,
// OsmAnd files its favourites into groups by <type>; Garmin has only <sym>.
category: text(el.type) ?? text(el.sym),
address,
labels: someOf(
asArray(own('label'))
.map(text)
.filter((label): label is string => label !== null && LABEL_NAME.safeParse(label).success)
.slice(0, MAX_COLLECTION_FILE_LABELS),
),
links: someOf([...moreLinks, ...ownLinks]),
};
for (const field of SCALAR_EXTENSIONS) {
const value = text(own(field));
if (field === 'address' || value === null) continue;
raw[field] = field === 'price' ? Number(value) : value;
}
return fit(collectionFilePlaceSchema, raw, ['name', 'lat', 'lng']);
}
function listName(root: XmlNode, meta: XmlNode, fileName: string | undefined): string {
const fromFile = fileName?.split(/[\\/]/).pop()?.replace(/\.gpx$/i, '').trim();
// GPX 1.1 keeps the name in <metadata>, GPX 1.0 on the root.
const name = text(meta.name) ?? text(root.name) ?? (fromFile || 'GPX');
return clip(name, collectionFileSchema.shape.name.maxLength);
}
/** Everything that can be refused without building a tree, checked before one is built. */
function refuseBeforeParsing(source: string): string {
// Bytes, not characters: the limit is the JSON file's, and a megabyte of
// text outside Latin-1 is fewer characters than that.
if (Buffer.byteLength(source, 'utf8') > MAX_COLLECTION_FILE_BYTES) {
throw new CollectionGpxError('too-large', 'That GPX file is too large');
}
const xml = source.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
// GPX has no use for a DTD, and a DTD is where both classic XML attacks
// live: external entities that read files or URLs, and nested ones that
// expand a kilobyte into gigabytes. The parser refuses the first and caps
// the second; refusing the declaration outright means relying on neither.
if (/<!(?:DOCTYPE|ENTITY)/i.test(xml)) {
throw new CollectionGpxError('unreadable', 'A GPX file with a DOCTYPE is not read');
}
let elements = 0;
for (let at = xml.indexOf('<'); at !== -1; at = xml.indexOf('<', at + 1)) {
// `</`, `<!` and `<?` close an element or open a comment, CDATA or declaration.
const next = xml.charCodeAt(at + 1);
if (next !== 0x2f && next !== 0x21 && next !== 0x3f) elements += 1;
if (elements > MAX_GPX_ELEMENTS) throw new CollectionGpxError('unreadable', 'That GPX file has too many elements');
}
return xml;
}
/**
* A GPX document as the list file it amounts to.
*
* Every waypoint becomes a place, and so does a route point somebody named;
* an unnamed route point is a bend in the road and a track is a line, neither
* a place, so both are only counted for the importer to be told about.
*/
export function gpxToCollectionFile(source: string, fileName?: string): CollectionGpxReadResult {
const xml = refuseBeforeParsing(source);
let doc: unknown;
try {
doc = reader.parse(xml, true);
} catch {
throw new CollectionGpxError('unreadable', 'That GPX file is not well-formed XML');
}
const rootValue = node(doc).gpx;
if (rootValue === undefined) throw new CollectionGpxError('not-gpx', 'That is not a GPX file');
const root = node(rootValue);
const prefixes: Prefixes = {
trek: prefixFor(root, [COLLECTION_GPX_NAMESPACE]),
osmand: prefixFor(root, OSMAND_NAMESPACES),
};
const places: CollectionFilePlace[] = [];
const seen = new Set<string>();
let skipped = 0;
let trackPoints = 0;
const take = (value: unknown, fallbackName: string | null, routePoint: boolean) => {
const place = readPlace(value, fallbackName, prefixes);
if (!place) {
skipped += 1;
return;
}
// A route drawn through waypoints repeats each of them as a route point
// (Garmin does this for every route); the place is already on the list.
const key = `${place.lat},${place.lng},${place.name}`;
if (routePoint && seen.has(key)) return;
seen.add(key);
places.push(place);
};
asArray(root.wpt).forEach((wpt, i) => take(wpt, `Waypoint ${i + 1}`, false));
for (const rte of asArray(root.rte)) {
for (const point of asArray(node(rte).rtept)) {
if (text(node(point).name)) take(point, null, true);
else trackPoints += 1;
}
}
for (const trk of asArray(root.trk)) {
for (const segment of asArray(node(trk).trkseg)) trackPoints += asArray(node(segment).trkpt).length;
}
if (places.length > MAX_COLLECTION_FILE_PLACES) {
throw new CollectionGpxError('too-many-places', `That GPX file has more than ${MAX_COLLECTION_FILE_PLACES} places`);
}
const meta = node(root.metadata);
const metaExt = node(meta.extensions);
const own = (field: string): unknown => (prefixes.trek ? metaExt[`${prefixes.trek}:${field}`] : undefined);
const labels = asArray(own('label'))
.map(label => fit(collectionFileLabelSchema, { name: text(label), color: attr(label, 'color') }, ['name']))
.filter((label): label is CollectionFileLabel => label !== null)
.slice(0, MAX_COLLECTION_FILE_LABELS);
const file = fit(
collectionFileSchema,
{
format: COLLECTION_FILE_FORMAT,
version: COLLECTION_FILE_VERSION,
name: listName(root, meta, fileName),
description: text(meta.desc) ?? text(root.desc),
color: text(own('color')),
icon: text(own('icon')),
labels,
places,
},
['format', 'version', 'name', 'places'],
);
// The envelope above always fits today; this keeps a stricter contract from becoming a 500.
if (!file) throw new CollectionGpxError('unreadable', 'That GPX file could not be read as a list');
return { file, skipped, track_points: trackPoints };
}
@@ -52,8 +52,10 @@ import {
CollectionLabelUpdateDto,
CollectionLabelAssignDto,
CollectionImportDto,
CollectionGpxReadDto,
} from './collections.dto';
import { PlaceRatingDto } from '../places/places.dto';
import { CollectionGpxError } from './collection-gpx.helpers';
export const MAX_COVER_SIZE = 20 * 1024 * 1024;
// Duplicated on purpose from trips.controller.ts (historical parity — no
@@ -211,6 +213,24 @@ export class CollectionsController {
return this.collections.importCollection(user.id, body);
}
/**
* A GPX document read into the list file it amounts to (#2301). Creates
* nothing, hence 200: the browser shows what came out and sends it to
* /import above, so a GPX goes through the same contract and the same one
* transaction as a list file. A refusal carries a `code` the client
* translates, beside the English `error` every other route has.
*/
@Post('gpx/read')
@HttpCode(200)
readGpx(@Body() body: CollectionGpxReadDto) {
try {
return this.collections.readCollectionGpx(body);
} catch (err) {
if (!(err instanceof CollectionGpxError)) throw err;
throw new HttpException({ error: err.message, code: err.code }, err.code === 'too-large' ? 413 : 400);
}
}
@Post('copy-to-trip')
@HttpCode(200)
copyToTrip(@CurrentUser() user: User, @Body() body: CollectionCopyToTripDto) {
@@ -392,6 +412,17 @@ export class CollectionsController {
return this.collections.exportCollection(user.id, Number(id));
}
/**
* The list as GPX (#2301). JSON around the document rather than the bare
* XML, for the same reason the list file has no Content-Disposition: the
* browser makes the download, and it also needs to say how many places had
* no coordinates and were left out.
*/
@Get(':id/export/gpx')
exportCollectionGpx(@CurrentUser() user: User, @Param('id') id: string) {
return this.collections.exportCollectionGpx(user.id, Number(id));
}
@Get(':id/importable/:tripId')
importable(@CurrentUser() user: User, @Param('id') id: string, @Param('tripId') tripId: string) {
return this.collections.importablePlaces(user.id, Number(id), Number(tripId));
@@ -21,6 +21,7 @@ import {
collectionLabelUpdateRequestSchema,
collectionLabelAssignRequestSchema,
collectionImportRequestSchema,
collectionGpxReadRequestSchema,
} from '@trek/shared';
/**
@@ -51,3 +52,4 @@ export class CollectionLabelCreateDto extends createZodDto(collectionLabelCreate
export class CollectionLabelUpdateDto extends createZodDto(collectionLabelUpdateRequestSchema) {}
export class CollectionLabelAssignDto extends createZodDto(collectionLabelAssignRequestSchema) {}
export class CollectionImportDto extends createZodDto(collectionImportRequestSchema) {}
export class CollectionGpxReadDto extends createZodDto(collectionGpxReadRequestSchema) {}
@@ -19,11 +19,13 @@ import {
COLLECTION_FILE_VERSION,
MAX_COLLECTION_FILE_LABELS,
type PlaceMatchCandidate,
type CollectionFile,
type CollectionFileLabel,
type CollectionFilePlace,
type CollectionImportRequest,
type CollectionImportResult,
type CollectionGpxExport,
type CollectionGpxReadRequest,
type CollectionGpxReadResult,
} from '@trek/shared';
import type {
Collection,
@@ -44,6 +46,7 @@ import type {
CollectionImportablesResponse,
} from '@trek/shared';
import { NotificationsService } from '../notifications/notifications.service';
import { collectionFileToGpx, gpxToCollectionFile, type ExportedCollectionFile } from './collection-gpx.helpers';
/** Links are stored as a JSON TEXT column; parse on read, stringify on write. */
function parseLinks(raw: unknown): CollectionLink[] | undefined {
@@ -368,7 +371,7 @@ export class CollectionsService {
* and a viewer who can read all of this on screen loses nothing by having it
* as a file; what a viewer must not do is write, which no export does.
*/
exportCollection(userId: number, id: number): CollectionFile {
exportCollection(userId: number, id: number): ExportedCollectionFile {
this.assertAccess(userId, id);
const collection = this.getCollectionRow(id);
const labels = this.loadLabelsByCollection(id);
@@ -419,6 +422,25 @@ export class CollectionsService {
};
}
/**
* The list as GPX (#2301), written from the very file the export above
* returns. What may leave the instance is decided once, there, and a GPX can
* only ever carry less of it. Same access rule too, since it is the same read.
*/
exportCollectionGpx(userId: number, id: number): CollectionGpxExport {
return collectionFileToGpx(this.exportCollection(userId, id));
}
/**
* A GPX document read into the list file it amounts to. Touches no table:
* the file goes back to the browser to be shown, and comes back through
* importCollection like any other, so there is one import and one
* transaction whatever the format was.
*/
readCollectionGpx(body: CollectionGpxReadRequest): CollectionGpxReadResult {
return gpxToCollectionFile(body.gpx, body.file_name);
}
/**
* Read a file back as a new list of the caller's own.
*
+4 -3
View File
@@ -48,7 +48,7 @@ type Pt = { lat: number; lng: number; ele: number | null };
/** Coordinates are written with 7 decimals, ~11 mm, which is past what any consumer
* device resolves and keeps the file from carrying float noise. */
function coord(n: number): string {
export function coord(n: number): string {
return Number(n.toFixed(7)).toString();
}
@@ -81,7 +81,8 @@ function describe(place: GpxExportPlace): string | undefined {
return parts.length ? parts.join(', ') : undefined;
}
const builder = new XMLBuilder({
/** Shared with the list writer in collections/, which writes the same dialect. */
export const gpxBuilder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: '@_',
format: true,
@@ -168,7 +169,7 @@ export function buildGpx(input: GpxExportInput, opts: GpxExportOptions = {}): st
},
};
return builder.build(doc);
return gpxBuilder.build(doc);
}
/** Filenames land on the receiving filesystem, so its reserved characters are
+42 -1
View File
@@ -4,7 +4,8 @@
* CollectionsModule) against a temp SQLite db (full schema). Only the addon
* flag, websocket and notification send are mocked. Covers: the addon gate
* (404 before auth), auth, CRUD happy paths, invite/accept/decline, copy-to-trip,
* cross-user 404s and the non-owner 403 on /:id/available-users (no enumeration).
* cross-user 404s, the non-owner 403 on /:id/available-users (no enumeration), and
* a list out as GPX and back in through the reader and the import (#2301).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
@@ -362,6 +363,46 @@ describe('Collections e2e (real auth guard + real service + temp SQLite)', () =>
expect((await request(server).get(`/api/addons/collections/${col.id}/importable/${tripId}`).set('Cookie', sessionCookie(otherId))).status).toBe(404);
});
// ── GPX (#2301) ──────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-080: a list goes out as GPX and comes back through the reader and the import', async () => {
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
const col = (await request(server).post('/api/addons/collections').set(as(ownerId)).send({ name: 'Round trip' })).body;
await request(server).post('/api/addons/collections/places').set(as(ownerId))
.send({ collection_id: col.id, name: 'Pinned', lat: 41.9, lng: 12.48, status: 'want' });
await request(server).post('/api/addons/collections/places').set(as(ownerId)).send({ collection_id: col.id, name: 'Vague' });
const exported = await request(server).get(`/api/addons/collections/${col.id}/export/gpx`).set(as(ownerId));
expect(exported.status).toBe(200);
expect(exported.body).toMatchObject({ name: 'Round trip', waypoints: 1, omitted: 1 });
const read = await request(server).post('/api/addons/collections/gpx/read').set(as(otherId))
.send({ gpx: exported.body.gpx, file_name: 'round-trip.gpx' });
expect(read.status).toBe(200);
expect(read.body).toMatchObject({ skipped: 0, track_points: 0 });
const imported = await request(server).post('/api/addons/collections/import').set(as(otherId)).send({ file: read.body.file });
expect(imported.status).toBe(201);
expect(imported.body).toMatchObject({ imported: 1, skipped: 0 });
const detail = await request(server).get(`/api/addons/collections/${imported.body.collection.id}`).set(as(otherId));
expect(detail.body.places).toEqual([expect.objectContaining({ name: 'Pinned', lat: 41.9, lng: 12.48, status: 'want' })]);
});
it('COLLECTIONS-E2E-081: a refused GPX says why in a code, a body without one is refused by the contract', async () => {
const hostile = '<?xml version="1.0"?><!DOCTYPE gpx [<!ENTITY x SYSTEM "file:///etc/passwd">]>'
+ '<gpx><wpt lat="1" lon="1"><name>&x;</name></wpt></gpx>';
const refused = await request(server).post('/api/addons/collections/gpx/read').set('Cookie', sessionCookie(ownerId)).send({ gpx: hostile });
expect(refused.status).toBe(400);
expect(refused.body).toEqual({ error: expect.any(String), code: 'unreadable' });
expect((await request(server).post('/api/addons/collections/gpx/read').set('Cookie', sessionCookie(ownerId)).send({})).status).toBe(400);
expect((await request(server).post('/api/addons/collections/gpx/read').send({ gpx: '<gpx/>' })).status).toBe(401);
});
it('COLLECTIONS-E2E-082: a list the caller cannot see has no GPX either', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Mine' })).body;
expect((await request(server).get(`/api/addons/collections/${col.id}/export/gpx`).set('Cookie', sessionCookie(otherId))).status).toBe(404);
});
// ── delete ───────────────────────────────────────────────────────────────
it('COLLECTIONS-E2E-050: owner deletes; non-owner member cannot (403)', async () => {
const col = (await request(server).post('/api/addons/collections').set('Cookie', sessionCookie(ownerId)).send({ name: 'Doomed' })).body;
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Windows export" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>Wien</name>
</metadata>
<wpt lat="48.208493" lon="16.373118">
<name>Stephansdom</name>
<desc>South tower first,
the north tower has the bell</desc>
</wpt>
<wpt lat="48.198558" lon="16.371867">
<name>Naschmarkt</name>
</wpt>
</gpx>
+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns:wptx1="http://www.garmin.com/xmlschemas/WaypointExtension/v1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" creator="eTrex 32x" version="1.1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www8.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/WaypointExtension/v1 http://www8.garmin.com/xmlschemas/WaypointExtensionv1.xsd">
<metadata>
<link href="http://www.garmin.com">
<text>Garmin International</text>
</link>
<time>2024-06-01T08:00:00Z</time>
</metadata>
<wpt lat="47.268120" lon="11.393510">
<ele>574.2</ele>
<time>2024-06-01T07:42:10Z</time>
<name>007</name>
<cmt>Parking at the old town</cmt>
<desc>Parking at the old town</desc>
<sym>Parking Area</sym>
<extensions>
<gpxx:WaypointExtension>
<gpxx:DisplayMode>SymbolAndName</gpxx:DisplayMode>
<gpxx:Address>
<gpxx:StreetAddress>Rennweg 1</gpxx:StreetAddress>
<gpxx:City>Innsbruck</gpxx:City>
</gpxx:Address>
</gpxx:WaypointExtension>
</extensions>
</wpt>
<wpt lat="47.268563" lon="11.393436">
<ele>574.0</ele>
<name>Goldenes Dachl</name>
<cmt>Built 1500</cmt>
<desc>Late Gothic oriel with 2,657 gilded copper tiles</desc>
<link href="https://www.innsbruck.info/">
<text>innsbruck.info</text>
</link>
<sym>Flag, Blue</sym>
</wpt>
<wpt lat="95.100000" lon="11.400000">
<name>Past the pole</name>
<sym>Flag, Red</sym>
</wpt>
<wpt lat="" lon="11.400000">
<name>No latitude</name>
</wpt>
<wpt lat="47.270035" lon="11.391221">
<ele>577.9</ele>
<sym>Flag, Green</sym>
</wpt>
<rte>
<name>Old town walk</name>
<rtept lat="47.268563" lon="11.393436">
<name>Goldenes Dachl</name>
<sym>Flag, Blue</sym>
</rtept>
<rtept lat="47.267201" lon="11.393102"/>
<rtept lat="47.266306" lon="11.393774">
<name>Triumphpforte</name>
<sym>Waypoint</sym>
</rtept>
</rte>
</gpx>
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www8.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www8.garmin.com/xmlschemas/TrackPointExtensionv1.xsd" xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" xmlns:gpxpx="http://www.garmin.com/xmlschemas/PowerExtension/v1" creator="https://gpx.studio" version="1.1">
<metadata>
<name>Sintra loop</name>
<author>
<name>gpx.studio</name>
<link href="https://gpx.studio"></link>
</author>
</metadata>
<wpt lat="38.787636" lon="-9.390587">
<ele>412.3</ele>
<name>Pena Palace</name>
<desc>Book the palace interior online, the park is walk-in</desc>
<sym>Flag, Blue</sym>
</wpt>
<wpt lat="38.792558" lon="-9.389433">
<ele>398.0</ele>
<name>Moorish Castle</name>
</wpt>
<trk>
<name>Sintra loop</name>
<type>Hiking</type>
<trkseg>
<trkpt lat="38.797642" lon="-9.388412"><ele>207.4</ele></trkpt>
<trkpt lat="38.795131" lon="-9.389120"><ele>256.9</ele></trkpt>
<trkpt lat="38.793880" lon="-9.389664"><ele>301.2</ele></trkpt>
<trkpt lat="38.792558" lon="-9.389433"><ele>398.0</ele></trkpt>
<trkpt lat="38.790412" lon="-9.389981"><ele>421.6</ele></trkpt>
<trkpt lat="38.787636" lon="-9.390587"><ele>412.3</ele></trkpt>
</trkseg>
<trkseg>
<trkpt lat="38.786201" lon="-9.392110"><ele>388.0</ele><extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>19</gpxtpx:atemp></gpxtpx:TrackPointExtension></extensions></trkpt>
<trkpt lat="38.790977" lon="-9.396511"><ele>331.5</ele></trkpt>
</trkseg>
</trk>
</gpx>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE gpx [
<!ENTITY secret SYSTEM "file:///etc/passwd">
<!ENTITY lol "lol">
<!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
]>
<gpx version="1.1" creator="nobody in particular" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>&lol3;</name>
</metadata>
<wpt lat="48.8566" lon="2.3522">
<name>&secret;</name>
<desc>&lol3;</desc>
</wpt>
</gpx>
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1" creator="Organic Maps" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name>Kyoto &amp; Nara</name>
<desc>Temples, gardens and where to eat between them</desc>
</metadata>
<wpt lat="34.967146" lon="135.772695">
<name>伏見稲荷大社</name>
<desc>Start at 7, the upper gates are empty then</desc>
<extensions>
<color>#FFE51A1A</color>
</extensions>
</wpt>
<wpt lat="35.039370" lon="135.729243">
<name>Kinkaku-ji</name>
<desc><![CDATA[Golden Pavilion. Buy tickets at the gate & walk the garden loop clockwise.]]></desc>
<link href="https://www.shokoku-ji.jp/kinkakuji/"><text>shokoku-ji.jp</text></link>
<extensions>
<color>#FF0066CC</color>
</extensions>
</wpt>
<wpt lat="34.685087" lon="135.843012">
<name>Nara Park</name>
<link href="javascript:alert(1)"><text>Not a link</text></link>
</wpt>
<trk>
<name>Philosopher's Path</name>
<extensions>
<color>#FF1E96F0</color>
</extensions>
<trkseg>
<trkpt lat="35.026839" lon="135.794780"></trkpt>
<trkpt lat="35.024110" lon="135.795090"></trkpt>
<trkpt lat="35.020470" lon="135.795370"></trkpt>
<trkpt lat="35.016302" lon="135.795703"></trkpt>
<trkpt lat="35.011860" lon="135.795050"></trkpt>
</trkseg>
</trk>
</gpx>
+46
View File
@@ -0,0 +1,46 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<gpx version="1.1" creator="OsmAnd Maps 4.7.10 (4.7.10.10)" xmlns="http://www.topografix.com/GPX/1/1" xmlns:osmand="https://osmand.net" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name>favorites</name>
</metadata>
<wpt lat="38.7071037" lon="-9.1459227">
<time>2024-05-02T10:14:03Z</time>
<name>Time Out Market</name>
<desc>Go before noon, the queues start at one</desc>
<type>Restaurant</type>
<extensions>
<osmand:address>Av. 24 de Julho 49, 1200-479 Lisboa</osmand:address>
<osmand:icon>restaurants</osmand:icon>
<osmand:background>circle</osmand:background>
<osmand:color>#eecc22</osmand:color>
<osmand:amenity_subtype>food_court</osmand:amenity_subtype>
</extensions>
</wpt>
<wpt lat="38.7117512" lon="-9.1301264">
<time>2024-05-02T10:21:47Z</time>
<name>Miradouro de Santa Luzia</name>
<type>Viewpoints</type>
<extensions>
<osmand:icon>special_star</osmand:icon>
<osmand:background>circle</osmand:background>
<osmand:color>#10c0f0</osmand:color>
</extensions>
</wpt>
<wpt lat="38.6975462" lon="-9.2032131">
<time>2024-05-03T08:02:11Z</time>
<name>Pastéis de Belém</name>
<desc>Past&#233;is warm from the oven</desc>
<extensions>
<osmand:address>R. de Belém 84 92, 1300-085 Lisboa</osmand:address>
<osmand:icon>shop_pastry</osmand:icon>
<osmand:color>#eecc22</osmand:color>
<osmand:hidden>false</osmand:hidden>
</extensions>
</wpt>
<extensions>
<osmand:points_groups>
<group name="Restaurant" color="#eecc22" icon="restaurants" background="circle" />
<group name="Viewpoints" color="#10c0f0" icon="special_star" background="circle" />
</osmand:points_groups>
</extensions>
</gpx>
+26
View File
@@ -165,4 +165,30 @@ describe('Request body ceiling', () => {
.send({ email: 'a@b.c', password: 'x'.repeat(1024) });
expect(res.status).not.toBe(413);
});
// A list file may be a megabyte (#2198), and the two routes that carry one
// whole are measured against that instead (#2301). Only those two.
it('MISC-011: a list file over 100kb reaches the import and the GPX reader', async () => {
for (const route of ['/api/addons/collections/import', '/api/addons/collections/gpx/read']) {
const res = await request(app)
.post(route)
.set('Content-Type', 'application/json')
.send({ gpx: 'x'.repeat(300 * 1024) });
expect(res.status, route).not.toBe(413);
}
});
it('MISC-012: a list file past twice the file limit is still refused, and no other route got the larger ceiling', async () => {
const huge = await request(app)
.post('/api/addons/collections/gpx/read')
.set('Content-Type', 'application/json')
.send({ gpx: 'x'.repeat(2 * 1024 * 1024 + 1) });
expect(huge.status).toBe(413);
const elsewhere = await request(app)
.post('/api/addons/collections')
.set('Content-Type', 'application/json')
.send({ name: 'x'.repeat(200 * 1024) });
expect(elsewhere.status).toBe(413);
});
});
@@ -0,0 +1,308 @@
/**
* A list as GPX and back (#2301). Pure helpers, so no DB and no container.
*
* The fixtures under tests/fixtures/gpx are modelled on what the apps people
* keep places in actually write: OsmAnd favourites, Organic Maps, gpx.studio,
* a Garmin handheld, a Windows tool that adds a BOM and CRLF, and one hostile
* document. The round trip at the end is the promise the TREK extension makes.
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
import { XMLValidator } from 'fast-xml-parser';
import { COLLECTION_GPX_NAMESPACE, MAX_COLLECTION_FILE_BYTES, type CollectionFilePlace } from '@trek/shared';
import {
CollectionGpxError,
MAX_GPX_ELEMENTS,
collectionFileToGpx,
gpxToCollectionFile,
type ExportedCollectionFile,
} from '../../../src/nest/collections/collection-gpx.helpers';
const fixture = (name: string) => fs.readFileSync(path.join(__dirname, '../../fixtures/gpx', name), 'utf8');
const listFile = (over: Partial<ExportedCollectionFile> = {}): ExportedCollectionFile => ({
format: 'trek.collection', version: 1, name: 'Lisbon', places: [], ...over,
});
const place = (over: Partial<CollectionFilePlace> = {}): CollectionFilePlace => ({
name: 'Somewhere', lat: 38.7071, lng: -9.1459, status: 'idea', ...over,
});
/** The refusal a document gets, or a failure when it is read. */
function refusal(source: string): string {
try {
gpxToCollectionFile(source);
} catch (err) {
expect(err).toBeInstanceOf(CollectionGpxError);
return (err as CollectionGpxError).code;
}
throw new Error('expected the document to be refused');
}
const wptDoc = (body: string, rootAttrs = '') =>
`<?xml version="1.0"?><gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1"${rootAttrs}>${body}</gpx>`;
describe('collectionFileToGpx', () => {
it('GPX-COLL-001: writes each place as a waypoint in the order GPX 1.1 prescribes', () => {
const { gpx, waypoints, omitted } = collectionFileToGpx(listFile({
places: [place({
name: 'Time Out Market', description: 'Market hall', notes: 'Before noon',
address: 'Av. 24 de Julho 49', website: 'https://timeoutmarket.com/', category: 'Restaurant',
})],
}));
expect(XMLValidator.validate(gpx)).toBe(true);
expect({ waypoints, omitted }).toEqual({ waypoints: 1, omitted: 0 });
expect(gpx).toContain('<wpt lat="38.7071" lon="-9.1459">');
// name, cmt, desc, link, type, extensions: the xsd sequence, which strict readers enforce.
const order = ['<name>', '<cmt>', '<desc>', '<link ', '<type>', '<extensions>'].map(tag => gpx.indexOf(tag, gpx.indexOf('<wpt')));
expect(order.every((at, i) => at > -1 && (i === 0 || at > order[i - 1]))).toBe(true);
// Other apps show <desc>; the address rides under the description so they show it too.
expect(gpx).toContain('<desc>Market hall\n\nAv. 24 de Julho 49</desc>');
expect(gpx).toContain('<cmt>Before noon</cmt>');
expect(gpx).toContain('<link href="https://timeoutmarket.com/"/>');
expect(gpx).toContain('<type>Restaurant</type>');
});
it('GPX-COLL-002: carries what GPX has no element for in the TREK namespace', () => {
const { gpx } = collectionFileToGpx(listFile({
color: '#ef4444', icon: 'Utensils', description: 'Three days',
labels: [{ name: 'Must see', color: '#ff0000' }],
places: [place({
address: 'Rua 1', phone: '+351 1', status: 'want', price: 12.5, currency: 'EUR',
labels: ['Must see'], links: [{ url: 'https://menu.example', label: 'Menu' }, { url: 'https://bare.example' }],
})],
}));
expect(gpx).toContain(`xmlns:trek="${COLLECTION_GPX_NAMESPACE}"`);
for (const line of [
'<trek:address>Rua 1</trek:address>', '<trek:phone>+351 1</trek:phone>', '<trek:status>want</trek:status>',
'<trek:price>12.5</trek:price>', '<trek:currency>EUR</trek:currency>', '<trek:label>Must see</trek:label>',
'<trek:link href="https://menu.example">Menu</trek:link>', '<trek:link href="https://bare.example"/>',
'<trek:color>#ef4444</trek:color>', '<trek:icon>Utensils</trek:icon>', '<trek:label color="#ff0000">Must see</trek:label>',
]) expect(gpx, line).toContain(line);
expect(gpx).toContain('<metadata>\n <name>Lisbon</name>\n <desc>Three days</desc>');
});
it('GPX-COLL-003: leaves out a place without coordinates and says how many', () => {
const result = collectionFileToGpx(listFile({
places: [place({ name: 'Here' }), place({ name: 'Nowhere', lat: null, lng: null }), place({ name: 'Half', lng: null })],
}));
expect(result).toMatchObject({ name: 'Lisbon', waypoints: 1, omitted: 2 });
expect(result.gpx).toContain('<name>Here</name>');
expect(result.gpx).not.toContain('Nowhere');
});
it('GPX-COLL-004: writes a list with no coordinates at all as a valid, empty GPX', () => {
const result = collectionFileToGpx(listFile({ places: [place({ lat: null, lng: null })] }));
expect(result).toMatchObject({ waypoints: 0, omitted: 1 });
expect(XMLValidator.validate(result.gpx)).toBe(true);
expect(result.gpx).not.toContain('<wpt');
});
it('GPX-COLL-005: escapes markup and drops the characters XML cannot hold', () => {
const { gpx } = collectionFileToGpx(listFile({
name: 'Fish & Chips <best>',
places: [place({ name: 'A "quoted" & <tagged> place\u0001\u0008', description: "Bob's \u000bnotes" })],
}));
expect(XMLValidator.validate(gpx)).toBe(true);
expect(gpx).toContain('<name>Fish &amp; Chips &lt;best&gt;</name>');
const back = gpxToCollectionFile(gpx).file;
expect(back.name).toBe('Fish & Chips <best>');
expect(back.places[0]).toMatchObject({ name: 'A "quoted" & <tagged> place', description: "Bob's notes" });
});
it('GPX-COLL-006: never writes a link a reader should not follow', () => {
const { gpx } = collectionFileToGpx(listFile({
places: [place({ website: 'javascript:alert(1)', links: [{ url: 'ftp://files.example' }] })],
}));
expect(gpx).not.toContain('javascript:');
expect(gpx).not.toContain('ftp://');
});
});
describe('gpxToCollectionFile: what other apps write', () => {
it('GPX-COLL-010: OsmAnd favourites, with <type> as the group and the address in its namespace', () => {
const { file, skipped, track_points } = gpxToCollectionFile(fixture('osmand-favourites.gpx'), 'favourites.gpx');
expect({ skipped, track_points }).toEqual({ skipped: 0, track_points: 0 });
expect(file.name).toBe('favorites');
expect(file.places).toHaveLength(3);
expect(file.places[0]).toMatchObject({
name: 'Time Out Market', lat: 38.7071037, lng: -9.1459227, category: 'Restaurant',
description: 'Go before noon, the queues start at one', address: 'Av. 24 de Julho 49, 1200-479 Lisboa',
});
expect(file.places[1]).toMatchObject({ name: 'Miradouro de Santa Luzia', category: 'Viewpoints' });
// A numeric character reference is text like any other.
expect(file.places[2]).toMatchObject({ name: 'Pastéis de Belém', description: 'Pastéis warm from the oven' });
// OsmAnd's icon and colour are its own business, not TREK labels or statuses.
expect(file.places[0]).toMatchObject({ status: 'idea' });
expect(file.places[0]).not.toHaveProperty('labels', expect.arrayContaining([expect.anything()]));
});
it('GPX-COLL-011: Organic Maps, with CDATA, entities, a script link and a track', () => {
const { file, skipped, track_points } = gpxToCollectionFile(fixture('organic-maps.gpx'));
expect(file).toMatchObject({ name: 'Kyoto & Nara', description: 'Temples, gardens and where to eat between them' });
expect(file.places.map(p => (p as CollectionFilePlace).name)).toEqual(['伏見稲荷大社', 'Kinkaku-ji', 'Nara Park']);
expect(file.places[1]).toMatchObject({
description: 'Golden Pavilion. Buy tickets at the gate & walk the garden loop clockwise.',
website: 'https://www.shokoku-ji.jp/kinkakuji/',
});
// The javascript: link is gone; the park is not.
expect(file.places[2]).not.toHaveProperty('website');
expect({ skipped, track_points }).toEqual({ skipped: 0, track_points: 5 });
});
it('GPX-COLL-012: gpx.studio, waypoints beside a track of two segments', () => {
const { file, track_points } = gpxToCollectionFile(fixture('gpx-studio.gpx'));
// The metadata name, not the author's name nested under it.
expect(file.name).toBe('Sintra loop');
expect(file.places).toHaveLength(2);
expect(file.places[0]).toMatchObject({ name: 'Pena Palace', category: 'Flag, Blue' });
expect(track_points).toBe(8);
});
it('GPX-COLL-013: a Garmin handheld, with <sym>, twin <cmt>, bad waypoints and a route', () => {
const { file, skipped, track_points } = gpxToCollectionFile(fixture('garmin-sym.gpx'), 'C:\\GPS\\Innsbruck.gpx');
// No name in the document, so the file name it came in.
expect(file.name).toBe('Innsbruck');
expect(file.places.map(p => (p as CollectionFilePlace).name)).toEqual(['007', 'Goldenes Dachl', 'Waypoint 5', 'Triumphpforte']);
// A name of digits stays the text it was.
expect(file.places[0]).toMatchObject({ category: 'Parking Area', description: 'Parking at the old town' });
// Garmin writes <cmt> and <desc> alike; the copy is not kept twice.
expect(file.places[0]).not.toHaveProperty('notes');
expect(file.places[1]).toMatchObject({ notes: 'Built 1500', website: 'https://www.innsbruck.info/' });
// Past the pole and without a latitude: skipped, not guessed.
expect(skipped).toBe(2);
// The route's unnamed bend; its named points are places, the repeated one only once.
expect(track_points).toBe(1);
});
it('GPX-COLL-014: a file with a byte-order mark and CRLF line endings', () => {
const raw = fixture('bom-crlf.gpx');
// The fixture must really be what it claims, or this case tests nothing.
expect(raw.charCodeAt(0)).toBe(0xfeff);
expect(raw).toContain('\r\n');
const { file } = gpxToCollectionFile(raw);
expect(file.name).toBe('Wien');
expect(file.places).toHaveLength(2);
expect((file.places[0] as CollectionFilePlace).description).toBe('South tower first,\nthe north tower has the bell');
});
it('GPX-COLL-015: GPX 1.0 keeps the name on the root and a link as <url>', () => {
const { file } = gpxToCollectionFile(
'<gpx version="1.0" creator="old"><name>Old file</name><wpt lat="1" lon="2"><name>A</name>'
+ '<url>https://a.example</url><urlname>A site</urlname></wpt></gpx>',
);
expect(file.name).toBe('Old file');
expect(file.places[0]).toMatchObject({ website: 'https://a.example' });
});
});
describe('gpxToCollectionFile: what it refuses', () => {
it('GPX-COLL-020: a DOCTYPE, before anything is expanded or fetched', () => {
expect(refusal(fixture('hostile-doctype.gpx'))).toBe('unreadable');
expect(refusal(wptDoc('<wpt lat="1" lon="1"><name>x</name></wpt>').replace('<gpx', '<!ENTITY a "b"><gpx'))).toBe('unreadable');
});
it('GPX-COLL-021: a file that is not XML, not well-formed, or not GPX', () => {
expect(refusal('{"format":"trek.collection"}')).toBe('unreadable');
expect(refusal('<gpx><wpt lat="1" lon="2"><name>x</wpt></gpx>')).toBe('unreadable');
expect(refusal('<?xml version="1.0"?><kml><Document/></kml>')).toBe('not-gpx');
});
it('GPX-COLL-022: a file past the size limit, counted in bytes', () => {
// Three bytes a character: under the limit in characters, over it in bytes.
const wide = '€'.repeat(Math.floor(MAX_COLLECTION_FILE_BYTES / 2));
expect(wide.length).toBeLessThan(MAX_COLLECTION_FILE_BYTES);
expect(refusal(wptDoc(`<desc>${wide}</desc>`))).toBe('too-large');
});
it('GPX-COLL-023: a file opening more elements than any real GPX has', () => {
expect(refusal(wptDoc('<a/>'.repeat(MAX_GPX_ELEMENTS + 1)))).toBe('unreadable');
});
it('GPX-COLL-024: a file with more places than a list may hold', () => {
const many = Array.from({ length: 1001 }, (_, i) => `<wpt lat="1" lon="${i / 10}"/>`).join('');
expect(refusal(wptDoc(many))).toBe('too-many-places');
});
it('GPX-COLL-025: an empty GPX reads as a list of nothing, not as an error', () => {
expect(gpxToCollectionFile('<gpx/>', 'empty.gpx').file).toMatchObject({ name: 'empty', places: [] });
});
});
describe('gpxToCollectionFile: one bad field costs that field', () => {
it('GPX-COLL-030: drops what the contract refuses and keeps the place', () => {
const { file, skipped } = gpxToCollectionFile(wptDoc(
`<wpt lat="1" lon="2"><name>${'n'.repeat(600)}</name><desc>${'d'.repeat(6000)}</desc>`
+ '<type>Museum</type></wpt>',
));
expect(skipped).toBe(0);
const only = file.places[0] as CollectionFilePlace;
expect(only.name).toHaveLength(500);
expect(only).not.toHaveProperty('description');
expect(only.category).toBe('Museum');
});
it('GPX-COLL-031: reads the TREK extension by namespace, whatever the prefix', () => {
const body = '<wpt lat="1" lon="2"><name>A</name><extensions><t:status>visited</t:status><trek:status>want</trek:status></extensions></wpt>';
const ours = gpxToCollectionFile(wptDoc(body, ` xmlns:t="${COLLECTION_GPX_NAMESPACE}" xmlns:trek="https://elsewhere.example"`));
expect(ours.file.places[0]).toMatchObject({ status: 'visited' });
// Without the namespace bound, `trek:` is just somebody else's prefix.
const theirs = gpxToCollectionFile(wptDoc(body, ' xmlns:trek="https://elsewhere.example"'));
expect(theirs.file.places[0]).toMatchObject({ status: 'idea' });
});
it('GPX-COLL-032: an unknown status, a price that is not a number and a bad label cost only themselves', () => {
const { file } = gpxToCollectionFile(wptDoc(
'<wpt lat="1" lon="2"><name>A</name><extensions><trek:status>loved</trek:status><trek:price>cheap</trek:price>'
+ `<trek:label>Ok</trek:label><trek:label>${'x'.repeat(61)}</trek:label><trek:phone>+1</trek:phone></extensions></wpt>`,
` xmlns:trek="${COLLECTION_GPX_NAMESPACE}"`,
));
expect(file.places[0]).toMatchObject({ status: 'idea', labels: ['Ok'], phone: '+1' });
expect(file.places[0]).not.toHaveProperty('price');
});
});
describe('a GPX made by TREK comes back as the same list (#2301)', () => {
it('GPX-COLL-040: every field of every place, and the list around them', () => {
const places: CollectionFilePlace[] = [
{
name: 'Time Out Market', description: 'Market hall\nwith two floors', notes: 'Before noon',
lat: 38.70710371234, lng: -9.14592271234, address: 'Av. 24 de Julho 49', phone: '+351 210 606 040',
website: 'https://timeoutmarket.com/', category: 'Restaurant', status: 'want', price: 12.5, currency: 'EUR',
image_url: 'https://example.com/market.jpg', google_place_id: 'ChIJ123', google_ftid: '0x1:0x2', osm_id: 'node/1',
labels: ['Must see', 'Rainy day'], links: [{ url: 'https://menu.example/', label: 'Menu' }],
},
{ name: 'Address only', lat: 38.7, lng: -9.1, address: 'Rua 2', status: 'visited' },
{ name: 'Bare', lat: 0, lng: 0, status: 'idea' },
];
const original = listFile({
name: 'Lisbon', description: 'Three days', color: '#ef4444', icon: 'Utensils', exported_at: '2026-09-19T10:00:00.000Z',
labels: [{ name: 'Must see', color: '#ff0000' }, { name: 'Rainy day', color: null }],
places,
});
const { file } = gpxToCollectionFile(collectionFileToGpx(original).gpx);
expect(file).toMatchObject({ name: 'Lisbon', description: 'Three days', color: '#ef4444', icon: 'Utensils' });
expect(file.labels).toEqual([{ name: 'Must see', color: '#ff0000' }, { name: 'Rainy day' }]);
const back = file.places as CollectionFilePlace[];
expect(back).toHaveLength(3);
// Seven decimals is what the writer keeps, about a centimetre.
expect(back[0].lat).toBeCloseTo(places[0].lat!, 7);
expect(back[0].lng).toBeCloseTo(places[0].lng!, 7);
expect({ ...back[0], lat: 0, lng: 0 }).toEqual({ ...places[0], lat: 0, lng: 0 });
// The address that was also written under the description does not come back into it.
expect(back[1]).toEqual(places[1]);
expect(back[2]).toEqual(places[2]);
});
});
@@ -4,7 +4,9 @@ import { HttpException } from '@nestjs/common';
import { CollectionsController } from '../../../src/nest/collections/collections.controller';
import type { CollectionsService } from '../../../src/nest/collections/collections.service';
import type { StorageService } from '../../../src/nest/storage/storage.service';
import { CollectionGpxError } from '../../../src/nest/collections/collection-gpx.helpers';
import type { User } from '../../../src/types';
import type { CollectionGpxProblem } from '@trek/shared';
const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as StorageService;
@@ -249,4 +251,43 @@ describe('CollectionsController', () => {
expect(svc.assignLabels).toHaveBeenCalledWith(1, [5], [9], true, 'sock');
});
});
describe('GPX (#2301)', () => {
it('exports the list as GPX for the caller, with the id as a number', () => {
const exported = { name: 'Lisbon', gpx: '<gpx/>', waypoints: 0, omitted: 0 };
const svc = makeService({ exportCollectionGpx: vi.fn().mockReturnValue(exported) });
const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub);
expect(c.exportCollectionGpx(user, '3')).toBe(exported);
expect(svc.exportCollectionGpx).toHaveBeenCalledWith(1, 3);
});
it('hands back the file a GPX was read into', () => {
const read = { file: { name: 'Lisbon' }, skipped: 1, track_points: 4 };
const svc = makeService({ readCollectionGpx: vi.fn().mockReturnValue(read) });
const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub);
expect(c.readGpx({ gpx: '<gpx/>', file_name: 'lisbon.gpx' })).toBe(read);
expect(svc.readCollectionGpx).toHaveBeenCalledWith({ gpx: '<gpx/>', file_name: 'lisbon.gpx' });
});
it('turns a refusal into a 400 with a code the client can translate, 413 for size', () => {
const refuse = (code: CollectionGpxProblem) => makeService({
readCollectionGpx: vi.fn(() => { throw new CollectionGpxError(code, 'refused'); }),
});
for (const code of ['unreadable', 'not-gpx', 'too-many-places'] as const) {
const c = new CollectionsController(refuse(code), new RuntimeEnvService(), storageStub);
expect(thrown(() => c.readGpx({ gpx: 'x' }))).toEqual({ status: 400, body: { error: 'refused', code } });
}
const big = new CollectionsController(refuse('too-large'), new RuntimeEnvService(), storageStub);
expect(thrown(() => big.readGpx({ gpx: 'x' }))).toEqual({ status: 413, body: { error: 'refused', code: 'too-large' } });
});
it('lets any other failure through untouched', () => {
const svc = makeService({ readCollectionGpx: vi.fn(() => { throw new Error('disk on fire'); }) });
const c = new CollectionsController(svc, new RuntimeEnvService(), storageStub);
expect(() => c.readGpx({ gpx: 'x' })).toThrow('disk on fire');
});
});
});
@@ -1283,3 +1283,82 @@ describe('exportCollection / importCollection (#2198)', () => {
expect(svc.listCollections(owner.id).collections).toHaveLength(before);
});
});
// ── The same list as GPX (#2301) ─────────────────────────────────────────────
describe('exportCollectionGpx / readCollectionGpx (#2301)', () => {
const fixture = (name: string) => fs.readFileSync(path.join(__dirname, '../../fixtures/gpx', name), 'utf8');
it('COLLECTIONS-SVC-120: a list that goes out as GPX comes back as the same list', () => {
const owner = createUser(testDb).user;
const other = createUser(testDb).user;
const cat = createCategory(testDb, { name: 'Restaurant' });
const col = svc.createCollection(owner.id, { name: 'Lisbon', description: 'Three days', color: '#ef4444' });
const must = svc.createLabel(owner.id, col.id, 'Must see', '#ff0000');
svc.createLabel(owner.id, col.id, 'Rainy day', '#00ff00');
const market = svc.savePlace(owner.id, {
collection_id: col.id, name: 'Time Out Market', description: 'Market hall', lat: 38.7071, lng: -9.1459,
address: 'Av. 24 de Julho 49', notes: 'Before noon', website: 'https://timeoutmarket.com/',
phone: '+351 210 606 040', osm_id: 'node/1', status: 'want', category_id: cat.id, price: 12.5, currency: 'EUR',
links: [{ url: 'https://menu.example/', label: 'Menu' }],
}).place!;
svc.savePlace(owner.id, { collection_id: col.id, name: 'Miradouro', lat: 38.7195, lng: -9.1327, status: 'visited' });
svc.assignLabels(owner.id, [must.id], [market.id], false);
const original = svc.exportCollection(owner.id, col.id);
const exported = svc.exportCollectionGpx(owner.id, col.id);
const read = svc.readCollectionGpx({ gpx: exported.gpx, file_name: 'lisbon.gpx' });
const result = svc.importCollection(other.id, { file: read.file });
expect(exported).toMatchObject({ name: 'Lisbon', waypoints: 2, omitted: 0 });
expect(read).toMatchObject({ skipped: 0, track_points: 0 });
expect(result).toMatchObject({ imported: 2, skipped: 0 });
// Names, coordinates, notes, website, category, labels, status and the rest:
// exported again from the copy, the file is the one the original gave.
const again = svc.exportCollection(other.id, (result.collection as { id: number }).id);
expect({ ...again, exported_at: undefined }).toEqual({ ...original, exported_at: undefined });
});
it('COLLECTIONS-SVC-121: a place without coordinates is left out and counted, and a stranger gets nothing', () => {
const owner = createUser(testDb).user;
const stranger = createUser(testDb).user;
const col = svc.createCollection(owner.id, { name: 'Mixed' });
svc.savePlace(owner.id, { collection_id: col.id, name: 'Pinned', lat: 1, lng: 2 });
svc.savePlace(owner.id, { collection_id: col.id, name: 'Somewhere vague' });
const result = svc.exportCollectionGpx(owner.id, col.id);
expect(result).toMatchObject({ name: 'Mixed', waypoints: 1, omitted: 1 });
expect(result.gpx).toContain('<name>Pinned</name>');
expect(result.gpx).not.toContain('Somewhere vague');
expect(() => svc.exportCollectionGpx(stranger.id, col.id)).toThrow(expect.objectContaining({ status: 404 }));
});
it('COLLECTIONS-SVC-122: OsmAnd favourites become a list, their groups matched against the palette', () => {
const owner = createUser(testDb).user;
createCategory(testDb, { name: 'Restaurant' });
const read = svc.readCollectionGpx({ gpx: fixture('osmand-favourites.gpx'), file_name: 'favourites.gpx' });
const created = svc.importCollection(owner.id, { file: read.file, name: 'Lisbon favourites' }).collection as { id: number };
const detail = svc.getCollection(owner.id, created.id);
expect(detail.collection.name).toBe('Lisbon favourites');
expect(detail.places.map(p => [p.name, p.category?.name ?? null, p.address ?? null])).toEqual([
['Time Out Market', 'Restaurant', 'Av. 24 de Julho 49, 1200-479 Lisboa'],
// No "Viewpoints" in this palette, and a file does not get to add one.
['Miradouro de Santa Luzia', null, null],
['Pastéis de Belém', null, 'R. de Belém 84 92, 1300-085 Lisboa'],
]);
});
it('COLLECTIONS-SVC-123: reading a GPX writes nothing, whatever is in it', () => {
const owner = createUser(testDb).user;
const before = testDb.prepare('SELECT COUNT(*) AS n FROM collections').get() as { n: number };
svc.readCollectionGpx({ gpx: fixture('garmin-sym.gpx') });
expect(() => svc.readCollectionGpx({ gpx: fixture('hostile-doctype.gpx') })).toThrow(expect.objectContaining({ code: 'unreadable' }));
expect(testDb.prepare('SELECT COUNT(*) AS n FROM collections').get()).toEqual(before);
expect(svc.listCollections(owner.id).collections).toHaveLength(0);
});
});
@@ -0,0 +1,44 @@
import {
COLLECTION_GPX_PLACE_FIELDS,
MAX_COLLECTION_FILE_BYTES,
collectionFilePlaceSchema,
collectionGpxReadRequestSchema,
} from './collection-file.schema';
import { describe, expect, it } from 'vitest';
describe('COLLECTION_GPX_PLACE_FIELDS (#2301)', () => {
// The type already refuses a missing key; this is the same promise at runtime,
// for a field that reaches the schema through a spread the type cannot see.
it('gives every field of a place in a list file a home in a GPX waypoint', () => {
const fields = Object.keys(COLLECTION_GPX_PLACE_FIELDS).sort();
expect(fields).toEqual(Object.keys(collectionFilePlaceSchema.shape).sort());
});
it('keeps what GPX has an element for out of the extension', () => {
const native = Object.entries(COLLECTION_GPX_PLACE_FIELDS)
.filter(([, home]) => home === 'waypoint')
.map(([field]) => field);
expect(native.sort()).toEqual(['category', 'description', 'lat', 'lng', 'name', 'notes', 'website']);
});
});
describe('collectionGpxReadRequestSchema (#2301)', () => {
const accepts = (body: unknown) => collectionGpxReadRequestSchema.safeParse(body).success;
it('takes the document as text, with the file name optional', () => {
expect(collectionGpxReadRequestSchema.parse({ gpx: '<gpx/>' })).toEqual({ gpx: '<gpx/>' });
const named = collectionGpxReadRequestSchema.parse({ gpx: '<gpx/>', file_name: 'favourites.gpx' });
expect(named.file_name).toBe('favourites.gpx');
});
it('refuses an empty document and one past the size a list file may have', () => {
expect(accepts({ gpx: '' })).toBe(false);
expect(accepts({ gpx: 'x'.repeat(MAX_COLLECTION_FILE_BYTES + 1) })).toBe(false);
});
it('refuses anything that is not text', () => {
expect(accepts({ gpx: { wpt: [] } })).toBe(false);
expect(accepts({})).toBe(false);
});
});
@@ -48,8 +48,9 @@ import { z } from 'zod';
* ── What an imported file is trusted for ─────────────────────────────────
*
* Nothing. It is a stranger's JSON. It is read in the browser that chose it,
* never uploaded as a file, and what is sent to the server goes through this
* contract on both sides. Text is rendered as text; the two fields that would
* never uploaded as a file (a GPX is, as text, for the reasons given at the
* end), and what is sent to the server goes through this contract on both
* sides. Text is rendered as text; the two fields that would
* otherwise reach a browser API (`website` and a link's `url`) are pinned to
* http(s) by the schemas they borrow, so neither can be a `javascript:` value.
* The counts below are the other half: a file cannot ask the server for more
@@ -188,3 +189,91 @@ export interface CollectionImportResult {
/** Places the file carried that the contract refused. */
skipped: number;
}
/*
* ── The same list as GPX (#2301) ─────────────────────────────────────────
*
* GPX is what the apps a list is usually wanted in already read: OsmAnd,
* Organic Maps, a Garmin, gpx.studio. It is not a second file format with its
* own idea of what may leave. A GPX is written from the list file above, after
* the exporter has decided what travels, so it can only ever carry less.
*
* Each place becomes a waypoint, and a waypoint needs coordinates: a place
* without them is left out and counted, never guessed. What GPX 1.1 has an
* element for goes into that element, so every reader shows it. What it has
* none for travels in TREK's own extension namespace, so a GPX made by TREK
* comes back as the same place rather than a pin with a name.
*
* Reading goes the other way through the same contract: a GPX is turned into
* a list file by the server and imported like one, so nothing a GPX says gets
* past `collectionFilePlaceSchema`. The server reads it, sent as text, because
* XML is where the parser itself is the risk (entities, DTDs, sheer size), and
* the server has one parser, already used for trip GPX, whose limits are its
* own and tested, where a browser's differ by engine.
*/
/** TREK's extension namespace. Versioned in the path; nothing resolves it. */
export const COLLECTION_GPX_NAMESPACE = 'https://liketrek.com/xmlns/gpx/collection/1';
/**
* Where each field of a place goes in a waypoint.
*
* `waypoint` has a GPX element of its own (name, lat/lon, desc, cmt, link,
* type); `extension` travels in the namespace above, one element per field,
* named after the field. Keyed by the place contract, so a field added there
* does not build until somebody decides where it goes in a GPX.
*/
export const COLLECTION_GPX_PLACE_FIELDS = {
name: 'waypoint',
lat: 'waypoint',
lng: 'waypoint',
description: 'waypoint',
notes: 'waypoint',
website: 'waypoint',
category: 'waypoint',
address: 'extension',
phone: 'extension',
status: 'extension',
price: 'extension',
currency: 'extension',
image_url: 'extension',
google_place_id: 'extension',
google_ftid: 'extension',
osm_id: 'extension',
labels: 'extension',
links: 'extension',
} as const satisfies Record<keyof CollectionFilePlace, 'waypoint' | 'extension'>;
/**
* A GPX document to be read into a list file. Sent as text: the file is a
* stranger's XML, and it is parsed where the XML parser and its limits are.
*/
export const collectionGpxReadRequestSchema = z.object({
gpx: z.string().min(1).max(MAX_COLLECTION_FILE_BYTES),
/** The chosen file's name, which names the list when the document does not. */
file_name: z.string().max(255).optional(),
});
export type CollectionGpxReadRequest = z.infer<typeof collectionGpxReadRequestSchema>;
/** Why a GPX was not read, as the `code` of the 4xx that says so. */
export const COLLECTION_GPX_PROBLEMS = ['too-large', 'unreadable', 'not-gpx', 'too-many-places'] as const;
export type CollectionGpxProblem = (typeof COLLECTION_GPX_PROBLEMS)[number];
export interface CollectionGpxReadResult {
/** The list file the GPX amounts to, ready for the import above. */
file: CollectionFile;
/** Waypoints that could not become a place, mostly for want of coordinates. */
skipped: number;
/** Points of tracks and unnamed route points: lines, not places, so not imported. */
track_points: number;
}
export interface CollectionGpxExport {
/** The list's name, for the file name. */
name: string;
gpx: string;
/** Places written as waypoints. */
waypoints: number;
/** Places left out because they have no coordinates. */
omitted: number;
}
+13
View File
@@ -159,6 +159,19 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'هذا الملف أكبر من أن يكون قائمة.',
'collections.file.errorUnreadable': 'تعذّرت قراءة هذا الملف.',
'collections.file.errorNotACollection': 'هذا ليس ملف قائمة من TREK.',
'collections.file.formatTrek': 'قائمة TREK',
'collections.file.formatTrekHint': 'لنسخة TREK أخرى، مع التسميات والحالة',
'collections.file.formatGpxHint': 'نقاط طريق لتطبيقات OsmAnd وOrganic Maps وGarmin وغيرها من تطبيقات الخرائط',
'collections.file.gpxOmitted': 'لم يُضمَّن {count} من الأماكن في ملف GPX لأنها بلا إحداثيات',
'collections.file.gpxNothing': 'لا يملك أي من هذه الأماكن إحداثيات، فلا يوجد ما يُوضع في ملف GPX.',
'collections.file.reading': 'جارٍ قراءة الملف…',
'collections.file.gpxSkipped': 'سيُستبعد {count} من نقاط الطريق لعدم وجود إحداثيات صالحة لها.',
'collections.file.gpxTrack':
'يحتوي هذا الملف أيضًا على {count} من نقاط المسار. لا تُستورد المسارات، بل نقاط الطريق فقط.',
'collections.file.gpxEmpty': 'لا يحتوي ملف GPX هذا على نقاط طريق، فلا يوجد ما يُستورد.',
'collections.file.errorNotGpx': 'هذا ليس ملف GPX.',
'collections.file.errorTooManyPlaces':
'يحتوي هذا الملف على أكثر من {count} مكان. قسّمه واستورد الأجزاء واحدًا تلو الآخر.',
'collections.share.title': 'مشاركة القائمة',
'collections.share.titleNamed': 'مشاركة «{name}»',
+14
View File
@@ -160,6 +160,20 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Esse arquivo é grande demais para ser uma lista.',
'collections.file.errorUnreadable': 'Não foi possível ler esse arquivo.',
'collections.file.errorNotACollection': 'Esse não é um arquivo de lista do TREK.',
'collections.file.formatTrek': 'Lista do TREK',
'collections.file.formatTrekHint': 'Para outro TREK, com etiquetas e status',
'collections.file.formatGpxHint': 'Pontos de referência para OsmAnd, Organic Maps, Garmin e outros apps de mapa',
'collections.file.gpxOmitted': '{count} lugares não têm coordenadas e ficaram fora do arquivo GPX',
'collections.file.gpxNothing':
'Nenhum desses lugares tem coordenadas, então não há nada para colocar em um arquivo GPX.',
'collections.file.reading': 'Lendo o arquivo…',
'collections.file.gpxSkipped': '{count} pontos de referência sem coordenadas utilizáveis ficam de fora.',
'collections.file.gpxTrack':
'Este arquivo também tem {count} pontos de trilha. Trilhas não são importadas, apenas pontos de referência.',
'collections.file.gpxEmpty': 'Este arquivo GPX não tem pontos de referência, então não há nada para importar.',
'collections.file.errorNotGpx': 'Esse não é um arquivo GPX.',
'collections.file.errorTooManyPlaces':
'Esse arquivo tem mais de {count} lugares. Divida-o e importe as partes uma de cada vez.',
'collections.share.title': 'Compartilhar lista',
'collections.share.titleNamed': 'Compartilhar “{name}”',
+14
View File
@@ -151,6 +151,20 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Aquest fitxer és massa gran per ser una llista.',
'collections.file.errorUnreadable': 'No s’ha pogut llegir aquest fitxer.',
'collections.file.errorNotACollection': 'Això no és un fitxer de llista del TREK.',
'collections.file.formatTrek': 'Llista del TREK',
'collections.file.formatTrekHint': 'Per a un altre TREK, amb etiquetes i estat',
'collections.file.formatGpxHint': 'Punts de pas per a OsmAnd, Organic Maps, Garmin i altres aplicacions de mapes',
'collections.file.gpxOmitted': '{count} llocs no tenen coordenades i han quedat fora del fitxer GPX',
'collections.file.gpxNothing':
'Cap d’aquests llocs té coordenades, així que no hi ha res per posar en un fitxer GPX.',
'collections.file.reading': 'S’està llegint el fitxer…',
'collections.file.gpxSkipped': 'Es deixen fora {count} punts de pas sense coordenades vàlides.',
'collections.file.gpxTrack':
'Aquest fitxer també té {count} punts de traça. Les traces no s’importen, només els punts de pas.',
'collections.file.gpxEmpty': 'Aquest fitxer GPX no té punts de pas, així que no hi ha res per importar.',
'collections.file.errorNotGpx': 'Això no és un fitxer GPX.',
'collections.file.errorTooManyPlaces':
'Aquest fitxer té més de {count} llocs. Divideix-lo i importa’n les parts d’una en una.',
'collections.share.title': 'Compartir llista',
'collections.share.titleNamed': 'Compartir «{name}»',
+12
View File
@@ -159,6 +159,18 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Tento soubor je na seznam příliš velký.',
'collections.file.errorUnreadable': 'Tento soubor se nepodařilo přečíst.',
'collections.file.errorNotACollection': 'Toto není soubor se seznamem TREK.',
'collections.file.formatTrek': 'Seznam TREK',
'collections.file.formatTrekHint': 'Pro jiný TREK, se štítky a stavem',
'collections.file.formatGpxHint': 'Trasové body pro OsmAnd, Organic Maps, Garmin a další mapové aplikace',
'collections.file.gpxOmitted': 'Místa bez souřadnic vynechaná ze souboru GPX: {count}',
'collections.file.gpxNothing': 'Žádné z těchto míst nemá souřadnice, takže do souboru GPX není co uložit.',
'collections.file.reading': 'Načítání souboru…',
'collections.file.gpxSkipped': 'Trasové body bez použitelných souřadnic budou vynechány: {count}.',
'collections.file.gpxTrack': 'Soubor obsahuje také body stopy: {count}. Stopy se neimportují, jen trasové body.',
'collections.file.gpxEmpty': 'Tento soubor GPX neobsahuje žádné trasové body, takže není co importovat.',
'collections.file.errorNotGpx': 'Toto není soubor GPX.',
'collections.file.errorTooManyPlaces':
'Tento soubor obsahuje více než {count} míst. Rozdělte ho a importujte části postupně.',
'collections.share.title': 'Sdílet seznam',
'collections.share.titleNamed': 'Sdílet „{name}“',
+13
View File
@@ -161,6 +161,19 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Diese Datei ist zu groß für eine Liste.',
'collections.file.errorUnreadable': 'Diese Datei konnte nicht gelesen werden.',
'collections.file.errorNotACollection': 'Das ist keine TREK-Listendatei.',
'collections.file.formatTrek': 'TREK-Liste',
'collections.file.formatTrekHint': 'Für ein anderes TREK, mit Labels und Status',
'collections.file.formatGpxHint': 'Wegpunkte für OsmAnd, Organic Maps, Garmin und andere Karten-Apps',
'collections.file.gpxOmitted': '{count} Orte ohne Koordinaten fehlen in der GPX-Datei',
'collections.file.gpxNothing': 'Keiner dieser Orte hat Koordinaten, für eine GPX-Datei gibt es also nichts.',
'collections.file.reading': 'Datei wird gelesen…',
'collections.file.gpxSkipped': '{count} Wegpunkte ohne brauchbare Koordinaten bleiben weg.',
'collections.file.gpxTrack':
'Die Datei enthält außerdem {count} Trackpunkte. Tracks werden nicht importiert, nur Wegpunkte.',
'collections.file.gpxEmpty': 'Diese GPX-Datei enthält keine Wegpunkte, es gibt also nichts zu importieren.',
'collections.file.errorNotGpx': 'Das ist keine GPX-Datei.',
'collections.file.errorTooManyPlaces':
'Diese Datei enthält mehr als {count} Orte. Teile sie auf und importiere die Teile nacheinander.',
'collections.share.title': 'Liste teilen',
'collections.share.titleNamed': '„{name}“ teilen',
+12
View File
@@ -160,6 +160,18 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'That file is too large to be a list.',
'collections.file.errorUnreadable': 'That file could not be read.',
'collections.file.errorNotACollection': 'That is not a TREK list file.',
'collections.file.formatTrek': 'TREK list',
'collections.file.formatTrekHint': 'For another TREK, with labels and status',
'collections.file.formatGpxHint': 'Waypoints for OsmAnd, Organic Maps, Garmin and other map apps',
'collections.file.gpxOmitted': '{count} places have no coordinates and were left out of the GPX file',
'collections.file.gpxNothing': 'None of these places has coordinates, so there is nothing to put in a GPX file.',
'collections.file.reading': 'Reading the file…',
'collections.file.gpxSkipped': '{count} waypoints without usable coordinates are left out.',
'collections.file.gpxTrack': 'This file also has {count} track points. Tracks are not imported, only waypoints.',
'collections.file.gpxEmpty': 'This GPX file has no waypoints, so there is nothing to import.',
'collections.file.errorNotGpx': 'That is not a GPX file.',
'collections.file.errorTooManyPlaces':
'That file has more than {count} places. Split it and import the parts one at a time.',
'collections.share.title': 'Share list',
'collections.share.titleNamed': 'Share “{name}”',
+14
View File
@@ -160,6 +160,20 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Ese archivo es demasiado grande para ser una lista.',
'collections.file.errorUnreadable': 'No se ha podido leer ese archivo.',
'collections.file.errorNotACollection': 'Ese no es un archivo de lista de TREK.',
'collections.file.formatTrek': 'Lista de TREK',
'collections.file.formatTrekHint': 'Para otro TREK, con etiquetas y estado',
'collections.file.formatGpxHint': 'Puntos de ruta para OsmAnd, Organic Maps, Garmin y otras apps de mapas',
'collections.file.gpxOmitted': '{count} lugares no tienen coordenadas y se han quedado fuera del archivo GPX',
'collections.file.gpxNothing':
'Ninguno de estos lugares tiene coordenadas, así que no hay nada que poner en un archivo GPX.',
'collections.file.reading': 'Leyendo el archivo…',
'collections.file.gpxSkipped': 'Se dejan fuera {count} puntos de ruta sin coordenadas válidas.',
'collections.file.gpxTrack':
'Este archivo también tiene {count} puntos de traza. Las trazas no se importan, solo los puntos de ruta.',
'collections.file.gpxEmpty': 'Este archivo GPX no tiene puntos de ruta, así que no hay nada que importar.',
'collections.file.errorNotGpx': 'Ese no es un archivo GPX.',
'collections.file.errorTooManyPlaces':
'Ese archivo tiene más de {count} lugares. Divídelo e importa las partes de una en una.',
'collections.share.title': 'Compartir lista',
'collections.share.titleNamed': 'Compartir «{name}»',
+15
View File
@@ -160,6 +160,21 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Ce fichier est trop volumineux pour être une liste.',
'collections.file.errorUnreadable': 'Ce fichier n’a pas pu être lu.',
'collections.file.errorNotACollection': 'Ce n’est pas un fichier de liste TREK.',
'collections.file.formatTrek': 'Liste TREK',
'collections.file.formatTrekHint': 'Pour un autre TREK, avec étiquettes et statut',
'collections.file.formatGpxHint':
'Points de passage pour OsmAnd, Organic Maps, Garmin et d’autres applications cartographiques',
'collections.file.gpxOmitted': '{count} lieux sans coordonnées ont été laissés hors du fichier GPX',
'collections.file.gpxNothing':
'Aucun de ces lieux n’a de coordonnées, il n’y a donc rien à mettre dans un fichier GPX.',
'collections.file.reading': 'Lecture du fichier…',
'collections.file.gpxSkipped': '{count} points de passage sans coordonnées utilisables sont écartés.',
'collections.file.gpxTrack':
'Ce fichier contient aussi {count} points de trace. Les traces ne sont pas importées, seulement les points de passage.',
'collections.file.gpxEmpty': 'Ce fichier GPX ne contient aucun point de passage, il n’y a donc rien à importer.',
'collections.file.errorNotGpx': 'Ce n’est pas un fichier GPX.',
'collections.file.errorTooManyPlaces':
'Ce fichier contient plus de {count} lieux. Découpez-le et importez les parties une par une.',
'collections.share.title': 'Partager la liste',
'collections.share.titleNamed': 'Partager « {name} »',
+14
View File
@@ -160,6 +160,20 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Αυτό το αρχείο είναι πολύ μεγάλο για λίστα.',
'collections.file.errorUnreadable': 'Δεν ήταν δυνατή η ανάγνωση αυτού του αρχείου.',
'collections.file.errorNotACollection': 'Αυτό δεν είναι αρχείο λίστας του TREK.',
'collections.file.formatTrek': 'Λίστα TREK',
'collections.file.formatTrekHint': 'Για άλλο TREK, με ετικέτες και κατάσταση',
'collections.file.formatGpxHint': 'Σημεία διαδρομής για OsmAnd, Organic Maps, Garmin και άλλες εφαρμογές χαρτών',
'collections.file.gpxOmitted': '{count} μέρη χωρίς συντεταγμένες έμειναν εκτός του αρχείου GPX',
'collections.file.gpxNothing':
'Κανένα από αυτά τα μέρη δεν έχει συντεταγμένες, οπότε δεν υπάρχει τίποτα για αρχείο GPX.',
'collections.file.reading': 'Ανάγνωση αρχείου…',
'collections.file.gpxSkipped': '{count} σημεία διαδρομής χωρίς χρήσιμες συντεταγμένες μένουν εκτός.',
'collections.file.gpxTrack':
'Το αρχείο έχει επίσης {count} σημεία ίχνους. Τα ίχνη δεν εισάγονται, μόνο τα σημεία διαδρομής.',
'collections.file.gpxEmpty': 'Αυτό το αρχείο GPX δεν έχει σημεία διαδρομής, οπότε δεν υπάρχει τίποτα για εισαγωγή.',
'collections.file.errorNotGpx': 'Αυτό δεν είναι αρχείο GPX.',
'collections.file.errorTooManyPlaces':
'Αυτό το αρχείο έχει περισσότερα από {count} μέρη. Χωρίστε το και εισαγάγετε τα κομμάτια ένα ένα.',
'collections.share.title': 'Κοινή χρήση λίστας',
'collections.share.titleNamed': 'Κοινή χρήση «{name}»',
+13
View File
@@ -161,6 +161,19 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Ez a fájl túl nagy ahhoz, hogy lista legyen.',
'collections.file.errorUnreadable': 'Ezt a fájlt nem sikerült beolvasni.',
'collections.file.errorNotACollection': 'Ez nem TREK-listafájl.',
'collections.file.formatTrek': 'TREK-lista',
'collections.file.formatTrekHint': 'Másik TREK-hez, címkékkel és állapottal',
'collections.file.formatGpxHint': 'Útpontok OsmAndhez, Organic Mapshez, Garminhoz és más térképes alkalmazásokhoz',
'collections.file.gpxOmitted': '{count} hely koordináták nélkül kimaradt a GPX-fájlból',
'collections.file.gpxNothing': 'Egyik helynek sincs koordinátája, így nincs mit GPX-fájlba tenni.',
'collections.file.reading': 'Fájl beolvasása…',
'collections.file.gpxSkipped': '{count} használható koordináták nélküli útpont kimarad.',
'collections.file.gpxTrack':
'A fájlban {count} nyomvonalpont is van. A nyomvonalakat nem importáljuk, csak az útpontokat.',
'collections.file.gpxEmpty': 'Ebben a GPX-fájlban nincsenek útpontok, így nincs mit importálni.',
'collections.file.errorNotGpx': 'Ez nem GPX-fájl.',
'collections.file.errorTooManyPlaces':
'Ebben a fájlban több mint {count} hely van. Oszd fel, és importáld a részeket egyenként.',
'collections.share.title': 'Lista megosztása',
'collections.share.titleNamed': '„{name}” megosztása',
+13
View File
@@ -160,6 +160,19 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Berkas ini terlalu besar untuk sebuah daftar.',
'collections.file.errorUnreadable': 'Berkas ini tidak bisa dibaca.',
'collections.file.errorNotACollection': 'Ini bukan berkas daftar TREK.',
'collections.file.formatTrek': 'Daftar TREK',
'collections.file.formatTrekHint': 'Untuk TREK lain, lengkap dengan label dan status',
'collections.file.formatGpxHint': 'Titik lokasi untuk OsmAnd, Organic Maps, Garmin, dan aplikasi peta lain',
'collections.file.gpxOmitted': '{count} tempat tanpa koordinat tidak dimasukkan ke berkas GPX',
'collections.file.gpxNothing':
'Tidak ada tempat yang punya koordinat, jadi tidak ada yang bisa dimasukkan ke berkas GPX.',
'collections.file.reading': 'Membaca berkas…',
'collections.file.gpxSkipped': '{count} titik lokasi tanpa koordinat yang bisa dipakai tidak disertakan.',
'collections.file.gpxTrack': 'Berkas ini juga berisi {count} titik jejak. Jejak tidak diimpor, hanya titik lokasi.',
'collections.file.gpxEmpty': 'Berkas GPX ini tidak berisi titik lokasi, jadi tidak ada yang diimpor.',
'collections.file.errorNotGpx': 'Ini bukan berkas GPX.',
'collections.file.errorTooManyPlaces':
'Berkas ini berisi lebih dari {count} tempat. Pecah menjadi beberapa bagian dan impor satu per satu.',
'collections.share.title': 'Bagikan daftar',
'collections.share.titleNamed': 'Bagikan “{name}”',
+14
View File
@@ -160,6 +160,20 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Questo file è troppo grande per essere un elenco.',
'collections.file.errorUnreadable': 'Non è stato possibile leggere questo file.',
'collections.file.errorNotACollection': 'Questo non è un file di elenco TREK.',
'collections.file.formatTrek': 'Elenco TREK',
'collections.file.formatTrekHint': 'Per un altro TREK, con etichette e stato',
'collections.file.formatGpxHint': 'Punti di passaggio per OsmAnd, Organic Maps, Garmin e altre app di mappe',
'collections.file.gpxOmitted': '{count} luoghi senza coordinate sono rimasti fuori dal file GPX',
'collections.file.gpxNothing':
'Nessuno di questi luoghi ha coordinate, quindi non c’è niente da mettere in un file GPX.',
'collections.file.reading': 'Lettura del file…',
'collections.file.gpxSkipped': '{count} punti di passaggio senza coordinate utilizzabili restano fuori.',
'collections.file.gpxTrack':
'Questo file contiene anche {count} punti di traccia. Le tracce non vengono importate, solo i punti di passaggio.',
'collections.file.gpxEmpty': 'Questo file GPX non contiene punti di passaggio, quindi non c’è niente da importare.',
'collections.file.errorNotGpx': 'Questo non è un file GPX.',
'collections.file.errorTooManyPlaces':
'Questo file contiene più di {count} luoghi. Dividilo e importa le parti una alla volta.',
'collections.share.title': 'Condividi lista',
'collections.share.titleNamed': 'Condividi «{name}»',
+13
View File
@@ -159,6 +159,19 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'このファイルはリストとしては大きすぎます。',
'collections.file.errorUnreadable': 'このファイルを読み取れませんでした。',
'collections.file.errorNotACollection': 'これは TREK のリストファイルではありません。',
'collections.file.formatTrek': 'TREK リスト',
'collections.file.formatTrekHint': '別の TREK 向け。ラベルとステータスも含みます',
'collections.file.formatGpxHint': 'OsmAnd、Organic Maps、Garmin などの地図アプリ向けのウェイポイント',
'collections.file.gpxOmitted': '座標のない {count} 件の場所は GPX ファイルに含まれていません',
'collections.file.gpxNothing': 'どの場所にも座標がないため、GPX ファイルに入れるものがありません。',
'collections.file.reading': 'ファイルを読み込んでいます…',
'collections.file.gpxSkipped': '使える座標のないウェイポイント {count} 件は除外されます。',
'collections.file.gpxTrack':
'このファイルにはトラックポイントも {count} 件あります。トラックはインポートされず、ウェイポイントのみが対象です。',
'collections.file.gpxEmpty': 'この GPX ファイルにはウェイポイントがないため、インポートするものがありません。',
'collections.file.errorNotGpx': 'これは GPX ファイルではありません。',
'collections.file.errorTooManyPlaces':
'このファイルには {count} 件を超える場所があります。分割して、1 つずつインポートしてください。',
'collections.share.title': 'リストを共有',
'collections.share.titleNamed': '「{name}」を共有',
+12
View File
@@ -158,6 +158,18 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': '이 파일은 목록이라고 하기에 너무 큽니다.',
'collections.file.errorUnreadable': '이 파일을 읽을 수 없습니다.',
'collections.file.errorNotACollection': 'TREK 목록 파일이 아닙니다.',
'collections.file.formatTrek': 'TREK 목록',
'collections.file.formatTrekHint': '다른 TREK용, 라벨과 상태 포함',
'collections.file.formatGpxHint': 'OsmAnd, Organic Maps, Garmin 등 지도 앱용 웨이포인트',
'collections.file.gpxOmitted': '좌표가 없는 장소 {count}개는 GPX 파일에서 빠졌습니다',
'collections.file.gpxNothing': '좌표가 있는 장소가 없어 GPX 파일에 넣을 것이 없습니다.',
'collections.file.reading': '파일을 읽는 중…',
'collections.file.gpxSkipped': '쓸 수 있는 좌표가 없는 웨이포인트 {count}개는 제외됩니다.',
'collections.file.gpxTrack':
'이 파일에는 트랙 포인트 {count}개도 있습니다. 트랙은 가져오지 않고 웨이포인트만 가져옵니다.',
'collections.file.gpxEmpty': '이 GPX 파일에는 웨이포인트가 없어 가져올 것이 없습니다.',
'collections.file.errorNotGpx': 'GPX 파일이 아닙니다.',
'collections.file.errorTooManyPlaces': '이 파일에는 장소가 {count}개보다 많습니다. 나눠서 하나씩 가져오세요.',
'collections.share.title': '목록 공유',
'collections.share.titleNamed': '“{name}” 공유',
+14
View File
@@ -160,6 +160,20 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Dit bestand is te groot om een lijst te zijn.',
'collections.file.errorUnreadable': 'Dit bestand kon niet worden gelezen.',
'collections.file.errorNotACollection': 'Dit is geen TREK-lijstbestand.',
'collections.file.formatTrek': 'TREK-lijst',
'collections.file.formatTrekHint': 'Voor een andere TREK, met labels en status',
'collections.file.formatGpxHint': 'Waypoints voor OsmAnd, Organic Maps, Garmin en andere kaart-apps',
'collections.file.gpxOmitted': '{count} plekken zonder coördinaten zijn niet in het GPX-bestand opgenomen',
'collections.file.gpxNothing':
'Geen van deze plekken heeft coördinaten, dus er is niets om in een GPX-bestand te zetten.',
'collections.file.reading': 'Bestand wordt gelezen…',
'collections.file.gpxSkipped': '{count} waypoints zonder bruikbare coördinaten worden overgeslagen.',
'collections.file.gpxTrack':
'Dit bestand bevat ook {count} trackpunten. Tracks worden niet geïmporteerd, alleen waypoints.',
'collections.file.gpxEmpty': 'Dit GPX-bestand bevat geen waypoints, dus er valt niets te importeren.',
'collections.file.errorNotGpx': 'Dit is geen GPX-bestand.',
'collections.file.errorTooManyPlaces':
'Dit bestand bevat meer dan {count} plekken. Splits het op en importeer de delen een voor een.',
'collections.share.title': 'Lijst delen',
'collections.share.titleNamed': '“{name}” delen',
+12
View File
@@ -160,6 +160,18 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Ten plik jest za duży, aby był listą.',
'collections.file.errorUnreadable': 'Nie udało się odczytać tego pliku.',
'collections.file.errorNotACollection': 'To nie jest plik listy TREK.',
'collections.file.formatTrek': 'Lista TREK',
'collections.file.formatTrekHint': 'Dla innego TREK, z etykietami i statusem',
'collections.file.formatGpxHint': 'Punkty trasy dla OsmAnd, Organic Maps, Garmina i innych aplikacji z mapami',
'collections.file.gpxOmitted': 'Miejsca bez współrzędnych pominięte w pliku GPX: {count}',
'collections.file.gpxNothing': 'Żadne z tych miejsc nie ma współrzędnych, więc nie ma czego zapisać w pliku GPX.',
'collections.file.reading': 'Wczytywanie pliku…',
'collections.file.gpxSkipped': 'Punkty trasy bez użytecznych współrzędnych zostaną pominięte: {count}.',
'collections.file.gpxTrack': 'Plik zawiera też punkty śladu: {count}. Ślady nie są importowane, tylko punkty trasy.',
'collections.file.gpxEmpty': 'Ten plik GPX nie zawiera punktów trasy, więc nie ma czego importować.',
'collections.file.errorNotGpx': 'To nie jest plik GPX.',
'collections.file.errorTooManyPlaces':
'Ten plik zawiera więcej niż {count} miejsc. Podziel go i importuj części po kolei.',
'collections.share.title': 'Udostępnij listę',
'collections.share.titleNamed': 'Udostępnij „{name}”',
+13
View File
@@ -159,6 +159,19 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Этот файл слишком велик для списка.',
'collections.file.errorUnreadable': 'Не удалось прочитать этот файл.',
'collections.file.errorNotACollection': 'Это не файл списка TREK.',
'collections.file.formatTrek': 'Список TREK',
'collections.file.formatTrekHint': 'Для другого TREK, с метками и статусом',
'collections.file.formatGpxHint':
'Путевые точки для OsmAnd, Organic Maps, Garmin и других картографических приложений',
'collections.file.gpxOmitted': 'Мест без координат, не попавших в файл GPX: {count}',
'collections.file.gpxNothing': 'Ни у одного из этих мест нет координат, поэтому в файл GPX нечего записать.',
'collections.file.reading': 'Чтение файла…',
'collections.file.gpxSkipped': 'Путевые точки без пригодных координат будут пропущены: {count}.',
'collections.file.gpxTrack': 'В файле также есть точки трека: {count}. Треки не импортируются, только путевые точки.',
'collections.file.gpxEmpty': 'В этом файле GPX нет путевых точек, поэтому импортировать нечего.',
'collections.file.errorNotGpx': 'Это не файл GPX.',
'collections.file.errorTooManyPlaces':
'В этом файле больше {count} мест. Разделите его и импортируйте части по очереди.',
'collections.share.title': 'Поделиться списком',
'collections.share.titleNamed': 'Поделиться «{name}»',
+13
View File
@@ -159,6 +159,19 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Filen är för stor för att vara en lista.',
'collections.file.errorUnreadable': 'Filen gick inte att läsa.',
'collections.file.errorNotACollection': 'Det här är ingen TREK-listfil.',
'collections.file.formatTrek': 'TREK-lista',
'collections.file.formatTrekHint': 'För en annan TREK, med etiketter och status',
'collections.file.formatGpxHint': 'Vägpunkter för OsmAnd, Organic Maps, Garmin och andra kartappar',
'collections.file.gpxOmitted': '{count} platser utan koordinater kom inte med i GPX-filen',
'collections.file.gpxNothing':
'Ingen av de här platserna har koordinater, så det finns inget att lägga i en GPX-fil.',
'collections.file.reading': 'Läser filen…',
'collections.file.gpxSkipped': '{count} vägpunkter utan användbara koordinater lämnas utanför.',
'collections.file.gpxTrack': 'Filen innehåller också {count} spårpunkter. Spår importeras inte, bara vägpunkter.',
'collections.file.gpxEmpty': 'Den här GPX-filen har inga vägpunkter, så det finns inget att importera.',
'collections.file.errorNotGpx': 'Det här är ingen GPX-fil.',
'collections.file.errorTooManyPlaces':
'Filen har fler än {count} platser. Dela upp den och importera delarna en i taget.',
'collections.share.title': 'Dela lista',
'collections.share.titleNamed': 'Dela ”{name}”',
+13
View File
@@ -160,6 +160,19 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Bu dosya liste olamayacak kadar büyük.',
'collections.file.errorUnreadable': 'Bu dosya okunamadı.',
'collections.file.errorNotACollection': 'Bu bir TREK liste dosyası değil.',
'collections.file.formatTrek': 'TREK listesi',
'collections.file.formatTrekHint': 'Başka bir TREK için, etiketler ve durumla birlikte',
'collections.file.formatGpxHint': 'OsmAnd, Organic Maps, Garmin ve diğer harita uygulamaları için yol noktaları',
'collections.file.gpxOmitted': 'Koordinatı olmayan {count} yer GPX dosyasına eklenmedi',
'collections.file.gpxNothing': 'Bu yerlerin hiçbirinin koordinatı yok, bu yüzden GPX dosyasına konacak bir şey yok.',
'collections.file.reading': 'Dosya okunuyor…',
'collections.file.gpxSkipped': 'Kullanılabilir koordinatı olmayan {count} yol noktası dışarıda bırakılıyor.',
'collections.file.gpxTrack':
'Bu dosyada ayrıca {count} iz noktası var. İzler içe aktarılmaz, yalnızca yol noktaları.',
'collections.file.gpxEmpty': 'Bu GPX dosyasında yol noktası yok, bu yüzden içe aktarılacak bir şey yok.',
'collections.file.errorNotGpx': 'Bu bir GPX dosyası değil.',
'collections.file.errorTooManyPlaces':
'Bu dosyada {count} yerden fazlası var. Dosyayı bölüp parçaları tek tek içe aktar.',
'collections.share.title': 'Listeyi paylaş',
'collections.share.titleNamed': '“{name}” listesini paylaş',
+13
View File
@@ -159,6 +159,19 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Цей файл завеликий, щоб бути списком.',
'collections.file.errorUnreadable': 'Не вдалося прочитати цей файл.',
'collections.file.errorNotACollection': 'Це не файл списку TREK.',
'collections.file.formatTrek': 'Список TREK',
'collections.file.formatTrekHint': 'Для іншого TREK, з мітками й статусом',
'collections.file.formatGpxHint':
'Маршрутні точки для OsmAnd, Organic Maps, Garmin та інших картографічних застосунків',
'collections.file.gpxOmitted': 'Місць без координат, які не потрапили до файлу GPX: {count}',
'collections.file.gpxNothing': 'Жодне з цих місць не має координат, тож у файл GPX нічого записати.',
'collections.file.reading': 'Читання файлу…',
'collections.file.gpxSkipped': 'Маршрутні точки без придатних координат буде пропущено: {count}.',
'collections.file.gpxTrack': 'У файлі також є точки треку: {count}. Треки не імпортуються, лише маршрутні точки.',
'collections.file.gpxEmpty': 'У цьому файлі GPX немає маршрутних точок, тож імпортувати нічого.',
'collections.file.errorNotGpx': 'Це не файл GPX.',
'collections.file.errorTooManyPlaces':
'У цьому файлі понад {count} місць. Розділіть його та імпортуйте частини по черзі.',
'collections.share.title': 'Поділитися списком',
'collections.share.titleNamed': 'Поділитися «{name}»',
+12
View File
@@ -160,6 +160,18 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': 'Tệp này quá lớn để là một danh sách.',
'collections.file.errorUnreadable': 'Không đọc được tệp này.',
'collections.file.errorNotACollection': 'Đây không phải tệp danh sách của TREK.',
'collections.file.formatTrek': 'Danh sách TREK',
'collections.file.formatTrekHint': 'Cho một TREK khác, kèm nhãn và trạng thái',
'collections.file.formatGpxHint': 'Điểm tham chiếu cho OsmAnd, Organic Maps, Garmin và các ứng dụng bản đồ khác',
'collections.file.gpxOmitted': '{count} địa điểm không có tọa độ nên không có trong tệp GPX',
'collections.file.gpxNothing': 'Không địa điểm nào có tọa độ, nên không có gì để đưa vào tệp GPX.',
'collections.file.reading': 'Đang đọc tệp…',
'collections.file.gpxSkipped': '{count} điểm tham chiếu không có tọa độ dùng được sẽ bị bỏ qua.',
'collections.file.gpxTrack':
'Tệp này còn có {count} điểm của vết đường đi. Vết đường đi không được nhập, chỉ các điểm tham chiếu.',
'collections.file.gpxEmpty': 'Tệp GPX này không có điểm tham chiếu nào, nên không có gì để nhập.',
'collections.file.errorNotGpx': 'Đây không phải tệp GPX.',
'collections.file.errorTooManyPlaces': 'Tệp này có hơn {count} địa điểm. Hãy chia nhỏ và nhập từng phần một.',
'collections.share.title': 'Chia sẻ danh sách',
'collections.share.titleNamed': 'Chia sẻ “{name}”',
+11
View File
@@ -158,6 +158,17 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': '這個檔案太大,不像是一份清單。',
'collections.file.errorUnreadable': '無法讀取這個檔案。',
'collections.file.errorNotACollection': '這不是 TREK 清單檔案。',
'collections.file.formatTrek': 'TREK 清單',
'collections.file.formatTrekHint': '給其他 TREK 使用,包含標籤與狀態',
'collections.file.formatGpxHint': '供 OsmAnd、Organic Maps、Garmin 等地圖 App 使用的航點',
'collections.file.gpxOmitted': '{count} 個沒有座標的地點未寫入 GPX 檔案',
'collections.file.gpxNothing': '這些地點都沒有座標,沒有可寫入 GPX 檔案的內容。',
'collections.file.reading': '正在讀取檔案…',
'collections.file.gpxSkipped': '{count} 個沒有可用座標的航點將被略過。',
'collections.file.gpxTrack': '這個檔案還包含 {count} 個軌跡點。軌跡不會匯入,只匯入航點。',
'collections.file.gpxEmpty': '這個 GPX 檔案沒有航點,沒有可匯入的內容。',
'collections.file.errorNotGpx': '這不是 GPX 檔案。',
'collections.file.errorTooManyPlaces': '這個檔案包含超過 {count} 個地點。請分割後逐一匯入。',
'collections.share.title': '分享清單',
'collections.share.titleNamed': '分享「{name}」',
+11
View File
@@ -158,6 +158,17 @@ const collection: TranslationStrings = {
'collections.file.errorTooLarge': '该文件太大,不像是一个列表。',
'collections.file.errorUnreadable': '无法读取该文件。',
'collections.file.errorNotACollection': '这不是 TREK 列表文件。',
'collections.file.formatTrek': 'TREK 列表',
'collections.file.formatTrekHint': '用于其他 TREK,包含标签和状态',
'collections.file.formatGpxHint': '供 OsmAnd、Organic Maps、Garmin 等地图应用使用的航点',
'collections.file.gpxOmitted': '{count} 个没有坐标的地点未写入 GPX 文件',
'collections.file.gpxNothing': '这些地点都没有坐标,没有可写入 GPX 文件的内容。',
'collections.file.reading': '正在读取文件…',
'collections.file.gpxSkipped': '{count} 个没有可用坐标的航点将被略过。',
'collections.file.gpxTrack': '该文件还包含 {count} 个轨迹点。轨迹不会导入,只导入航点。',
'collections.file.gpxEmpty': '该 GPX 文件中没有航点,没有可导入的内容。',
'collections.file.errorNotGpx': '这不是 GPX 文件。',
'collections.file.errorTooManyPlaces': '该文件包含超过 {count} 个地点。请拆分后逐个导入。',
'collections.share.title': '共享列表',
'collections.share.titleNamed': '共享“{name}”',
+8 -3
View File
@@ -100,9 +100,14 @@ The owner always has full control. The owner can also remove a member, and a mem
Sharing works between people on the same TREK. To give a list to somebody who runs their own, or to keep a copy of your own, export it as a file.
**Export** sits next to Edit and Share in the list header, on desktop. It downloads the open list as a `.trekcollection.json` file: the list's name, description and colour, its labels, and every place with its address, coordinates, notes, status, website, phone, category name and labels. Any member of a shared list may export it.
**Export** sits next to Edit and Share in the list header, on desktop, and asks which format you want. Any member of a shared list may export it.
**Import** sits next to **New list** in the lists rail, as the button with the upload arrow. Pick a file and TREK shows what is in it before anything happens: the name, how many places, how many labels. Rename it there if you like, then import. The file always becomes a **new list of your own**, so nothing you already have can be overwritten, and you land on the new list when it is done.
- **TREK list** downloads the open list as a `.trekcollection.json` file: the list's name, description and colour, its labels, and every place with its address, coordinates, notes, status, website, phone, category name and labels. This is the one to give to another TREK.
- **GPX** downloads the places as waypoints in a `.gpx` file, for OsmAnd, Organic Maps, a Garmin, gpx.studio or any other app that reads GPX. Each waypoint carries the place's name, description and address, notes, website and category, which every such app shows. Labels, status, phone and the rest travel alongside in a TREK extension that other apps ignore, so a GPX made by TREK comes back into TREK complete. A place without coordinates cannot be a waypoint: it is left out, and TREK tells you how many were.
**Import** sits next to **New list** in the lists rail, as the button with the upload arrow. Pick a TREK list file or a GPX file and TREK shows what is in it before anything happens: the name, how many places, how many labels. Rename it there if you like, then import. The file always becomes a **new list of your own**, so nothing you already have can be overwritten, and you land on the new list when it is done.
From a GPX, every waypoint becomes a place, and so does a route point somebody named. A track is a line rather than a place, so it is not imported, and the preview says how many track points were left behind. A waypoint's type (OsmAnd files its favourites into groups this way) becomes the place's category when your palette has one of that name. A waypoint without a name is called "Waypoint" and its number in the file; one without usable coordinates is skipped and counted.
A few things deliberately stay behind, because they belong to the instance the file came from rather than to the list:
@@ -110,7 +115,7 @@ A few things deliberately stay behind, because they belong to the instance the f
- **Uploaded photos and cover images.** They live on the sender's server; a place keeps an image only when it is an ordinary `https://` address.
- **Ids of any kind**, including which trip a place was originally saved from. The receiving instance issues its own; a category comes across by name and is matched against the palette on the other side, or left empty when there is no match.
A file that is not a TREK list is refused with a reason. A single place inside a file that cannot be read is skipped and reported, so one bad line does not cost you the rest of the list.
A file that is neither a TREK list nor a GPX is refused with a reason. A single place inside a file that cannot be read is skipped and reported, so one bad line does not cost you the rest of the list.
## See also