feat(tours): import KML and KMZ routes as tours, with a preview first

A route planned in Google My Maps or Google Earth can now become a tour
without converting it to GPX first. The import button and a file dropped on
the tours workspace take .kml and .kmz files beside .gpx, through the same
preview, the same import routes and the same transaction as a GPX file.

The preview shows every line of the file on its own, numbered, with its
distance and heights; two lines of one placemark are numbered after its name
and never joined. Heights count only where they are heights: kept for an
absolute line, dropped for any other altitude mode and for the all-zero
placeholder My Maps writes, so a missing height stays unknown. What the file
holds besides its lines (pins, areas, overlays, links to other files) is
counted in a dashed card, and nothing is fetched from elsewhere.

The KML reading reuses the place import's codec. A KMZ only ever has its KML
entry unpacked, counted byte by byte against the decompressed cap whatever
its header claims. A file that cannot be read is refused with its reason
(damaged, an archive without KML, too large unpacked), and the toast names
the file and the reason. A KML or KMZ import keeps the gpx origin of a file
import; its provenance names the real format from the stored file name.
This commit is contained in:
Maurice
2026-10-20 12:28:57 +02:00
parent f57a0dbd31
commit 3659297af1
58 changed files with 1883 additions and 400 deletions
@@ -32,7 +32,7 @@ describe('TourGpxDropZone', () => {
const zone = screen.getByText('workspace').parentElement as HTMLElement;
fireEvent.dragEnter(zone, filesDrag);
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Drop GPX files to import them');
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Drop GPX, KML or KMZ files to import them');
fireEvent.drop(zone, filesDrag);
expect(importer.importFiles).toHaveBeenCalledWith([file]);
@@ -89,7 +89,7 @@ describe('TourGpxDropZone', () => {
<p>workspace</p>
</TourGpxDropZone>
);
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Importing GPX…');
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Importing…');
});
});
@@ -1,5 +1,5 @@
import type { TourGpxImportFields } from '@trek/shared';
import { AlertTriangle, Clock, FileDown } from 'lucide-react';
import type { TourFileLeftOut, TourGpxImportFields } from '@trek/shared';
import { AlertTriangle, Ban, Clock, FileDown, Layers, MapPin, Shapes } from 'lucide-react';
import { useId, useMemo } from 'react';
import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
@@ -22,7 +22,7 @@ import {
import { recordedDay, recordedMinutes } from './activity/tourActivityModel';
import { importFields, importMissing, useGpxImportChoices } from './gpxImportChoices';
import { GpxImportTypeSection } from './GpxImportTypeSection';
import { TourNotice } from './tourParts';
import { InfoDot, TourNotice } from './tourParts';
import { formatPlannedTourDuration } from './tourPresentation';
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
@@ -44,8 +44,20 @@ function RecordedBadge({ track }: { track: GpxPreviewFile['tracks'][number] }) {
);
}
/** One track of the file as a row: its name, and what it measures as badges; a track the trip holds says so. */
function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile['tracks'][number] }) {
/**
* One track of the file as a row: its number, its name, and what it measures
* as badges; a track the trip holds says so. The number keeps the lines of a
* file apart, they are imported one tour each and never joined.
*/
function PreviewTrackRow({
name,
number,
track,
}: {
name: string;
number: number;
track: GpxPreviewFile['tracks'][number];
}) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const heights = track.elevation_gain_m != null && track.elevation_loss_m != null;
@@ -56,12 +68,19 @@ function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile[
className={`flex min-w-0 flex-col gap-1.5 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 ${track.duplicate ? 'opacity-60' : ''}`}
>
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="grid h-6 w-6 flex-none place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content ring-2 ring-surface-card"
style={fs(11)}
>
{number}
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
{track.name || name}
</span>
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
</div>
<div className="flex flex-wrap items-center gap-1.5" style={fs(11)}>
<div className="flex flex-wrap items-center gap-1.5 ps-8" style={fs(11)}>
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
{heights ? (
<>
@@ -78,6 +97,53 @@ function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile[
);
}
/** What all the files held besides their lines, summed by kind; null when they held nothing else. */
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
const sum = { points: 0, areas: 0, other: 0 };
for (const { leftOut } of files) {
if (!leftOut) continue;
sum.points += leftOut.points;
sum.areas += leftOut.areas;
sum.other += leftOut.other;
}
return sum.points + sum.areas + sum.other > 0 ? sum : null;
}
/**
* IO-02: what a KML or KMZ file held besides its lines, named before the
* import so nothing goes missing unseen: a dashed card with each kind as a
* counted badge, and why in the info dot.
*/
function PreviewLeftOut({ files }: { files: readonly GpxPreviewFile[] }) {
const { t } = useTranslation();
const leftOut = leftOutOf(files);
if (!leftOut) return null;
const kinds = [
{ key: 'points', count: leftOut.points, icon: MapPin, labelKey: 'tours.import.leftOutPoints' },
{ key: 'areas', count: leftOut.areas, icon: Shapes, labelKey: 'tours.import.leftOutAreas' },
{ key: 'other', count: leftOut.other, icon: Layers, labelKey: 'tours.import.leftOutOther' },
].filter(({ count }) => count > 0);
return (
<div
data-testid="gpx-preview-left-out"
className="mt-2 flex flex-wrap items-center gap-1.5 rounded-[10px] border border-dashed border-edge px-3 py-2"
>
<Ban size={13} strokeWidth={2} className="flex-none text-content-faint" aria-hidden="true" />
<span className="me-0.5 font-semibold text-content-muted" style={fs(12, 'body')}>
{t('tours.import.previewLeftOut')}
</span>
{kinds.map(({ key, count, icon: Icon, labelKey }) => (
<SoftPill key={key} icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{t(labelKey, { count })}
</SoftPill>
))}
<span className="ms-auto">
<InfoDot label={t('tours.import.previewLeftOut')} description={t('tours.import.previewLeftOutHint')} />
</span>
</div>
);
}
/**
* PAD-02: a GPX file shown before anything is stored. The whole course on a
* map, every track with its measures, the tracks the trip already holds named
@@ -86,7 +152,8 @@ function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile[
* (SKI-02); a ride (HOR-02) says before anything is stored that its line stays
* as recorded, gets no riding time and is not checked for access. Closing it
* stores nothing. `initial` presets the type, as a recording imported for a
* tour takes that tour's (ACT-01); left out, it is a hike.
* tour takes that tour's (ACT-01); left out, it is a hike. A KML or KMZ file
* is shown the same way, with what it holds besides (IO-02).
*/
export default function TourGpxPreviewDialog({
gpxImport,
@@ -187,9 +254,10 @@ function PreviewDialog({
<DialogSection label={t('tours.import.previewTracks', { count: tracks.length })}>
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
{tracks.map(({ file, track }, index) => (
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} track={track} />
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} number={index + 1} track={track} />
))}
</ul>
<PreviewLeftOut files={files} />
{fresh === 0 && (
<div className="mt-2">
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
@@ -241,7 +241,7 @@ describe('ToursSidebar', () => {
/>)
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])).toHaveAttribute('accept', '.gpx')
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [file])).toHaveAttribute('accept', '.gpx,.kml,.kmz')
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
// Only the places are read again, not the whole trip: that would rebuild the workspace.
@@ -269,7 +269,7 @@ describe('ToursSidebar', () => {
onSelectTour={vi.fn()}
/>)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
])
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
@@ -288,7 +288,7 @@ describe('ToursSidebar', () => {
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [
new File(['<gpx/>'], 'empty.gpx', { type: 'application/gpx+xml' }),
new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' }),
])
@@ -296,7 +296,7 @@ describe('ToursSidebar', () => {
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx'))
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported from GPX'))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
expect(importCall).toHaveBeenCalledOnce()
expect(toast.info).toHaveBeenCalledWith('Imported with caution — elevation/metrics incomplete')
vi.restoreAllMocks()
@@ -308,10 +308,10 @@ describe('ToursSidebar', () => {
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
const dialog = await screen.findByRole('dialog')
expect(dialog).toHaveTextContent('GPX preview')
expect(dialog).toHaveTextContent('Import preview')
expect(dialog).toHaveTextContent('ridge.gpx')
expect(screen.getAllByTestId('gpx-preview-track')).toHaveLength(2)
expect(screen.getAllByTestId('gpx-preview-track')[1]).toHaveTextContent('Already in the trip')
@@ -330,7 +330,7 @@ describe('ToursSidebar', () => {
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
expect(await screen.findByText('Every track is already in the trip, so there is nothing to import.')).toBeInTheDocument()
expect(screen.getByTestId('gpx-preview-import')).toBeDisabled()
vi.restoreAllMocks()
@@ -344,7 +344,7 @@ describe('ToursSidebar', () => {
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<gpx/>'], 'lake.gpx', { type: 'application/gpx+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [file])
await screen.findByRole('dialog')
// A file without heights says so instead of showing zero.
expect(screen.getByTestId('gpx-preview-track')).toHaveTextContent('No heights in the file')
@@ -440,15 +440,88 @@ describe('ToursSidebar', () => {
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [
new File(['x'], 'notes.txt', { type: 'text/plain' }),
])
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Only GPX files can be imported as tours.'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Only GPX, KML and KMZ files can be imported as tours.'))
expect(importCall).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('IO-02: previews a KML file like a GPX file, numbers its lines and names what it leaves out, then imports it', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({
tracks: [previewTrack({ name: 'Coast (1/2)' }), previewTrack({ name: 'Coast (2/2)', elevation_gain_m: null, elevation_loss_m: null })],
left_out: { points: 3, areas: 1, other: 0 },
})
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
const file = new File(['<kml/>'], 'coast.kml', { type: 'application/vnd.google-earth.kml+xml' })
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [file])
const dialog = await screen.findByRole('dialog')
expect(dialog).toHaveTextContent('Import preview')
const rows = screen.getAllByTestId('gpx-preview-track')
expect(rows.map((row) => row.textContent?.slice(0, 1))).toEqual(['1', '2'])
expect(rows[1]).toHaveTextContent('No heights in the file')
const leftOut = screen.getByTestId('gpx-preview-left-out')
expect(leftOut).toHaveTextContent('Not imported')
expect(leftOut).toHaveTextContent('3 pins')
expect(leftOut).toHaveTextContent('1 area')
expect(leftOut).not.toHaveTextContent('other item')
expect(within(leftOut).getByRole('button', { name: 'Not imported' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Import 2 tours' }))
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
vi.restoreAllMocks()
})
it('IO-02: a GPX preview counts nothing it leaves out, so it shows no such card', async () => {
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
await screen.findByRole('dialog')
expect(screen.queryByTestId('gpx-preview-left-out')).toBeNull()
vi.restoreAllMocks()
})
it('IO-02: names each file the server could not read by the reason it gave, and opens no preview for them', async () => {
const refusal = (code: string) => ({ response: { data: { error: 'refused', code } } })
vi.spyOn(tourGpxApi, 'preview')
.mockRejectedValueOnce(refusal('malformed'))
.mockRejectedValueOnce(refusal('archive_without_kml'))
.mockRejectedValueOnce(refusal('archive_too_large'))
.mockRejectedValueOnce(refusal('no_track'))
.mockRejectedValueOnce(refusal('something_else'))
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
)
pickFiles(screen.getByRole('button', { name: 'Import GPX or KML' }), [
new File(['x'], 'broken.kml'),
new File(['x'], 'photos.kmz'),
new File(['x'], 'huge.KMZ'),
new File(['x'], 'pins.kml'),
new File(['x'], 'odd.kml'),
])
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(5))
expect(toast.error.mock.calls.map(([message]) => message)).toEqual([
'Could not read broken.kml: the file is damaged or not GPX, KML or KMZ',
'No KML file found in photos.kmz',
'huge.KMZ is too large once unpacked',
'No track or route found in pins.kml',
'Could not import odd.kml',
])
expect(screen.queryByRole('dialog')).toBeNull()
vi.restoreAllMocks()
})
it('RC-02: names the days a tour is on in one badge, in the order of the trip', () => {
const days = [
{ id: 7, trip_id: 1, day_number: 1, title: null, date: null },
@@ -54,7 +54,7 @@ describe('TourAddMenu (LIB-02)', () => {
expect(button).toHaveAttribute('aria-expanded', 'true');
expect(entries().map((entry) => entry.textContent)).toEqual([
'Plan new tour',
'Import GPX',
'Import GPX or KML',
'Import walk from Google Maps',
'Take over a tour from another trip',
]);
@@ -69,7 +69,7 @@ describe('TourAddMenu (LIB-02)', () => {
expect(p.startNewTour).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('group')).toBeNull();
open();
fireEvent.click(screen.getByText('Import GPX'));
fireEvent.click(screen.getByText('Import GPX or KML'));
expect(gpx.pick).toHaveBeenCalledTimes(1);
open();
fireEvent.click(screen.getByText('Take over a tour from another trip'));
@@ -80,7 +80,7 @@ describe('TourAddMenu (LIB-02)', () => {
render(<TourAddMenu planner={planner('neutral')} gpxImport={gpxImport()} onTakenOver={vi.fn()} disabled={false} />);
open();
expect(entries().map((entry) => entry.textContent)).toEqual([
'Import GPX',
'Import GPX or KML',
'Import walk from Google Maps',
'Take over a tour from another trip',
]);
@@ -23,7 +23,7 @@ interface AddEntry {
/**
* LIB-02: the tours rail's one way to add a tour, as a menu: plan a new one
* (while another is open; with none open the map is already planning one),
* import GPX files, read a city walk from a Google Maps walking route (CW-07),
* import GPX, KML or KMZ files, read a city walk from a Google Maps walking route (CW-07),
* or take one over from another trip. The walk and the take-over open their
* dialogs from here. A tour taken over comes into the trip and opens in the
* editor, unless the editor holds unsaved work; a walk read from Google Maps
@@ -61,6 +61,19 @@ describe('TourProvenanceSection (P2C-02, MF-03)', () => {
expect(screen.getByText('tours.provenance.plannedOver')).toBeInTheDocument();
});
it('names a KML or KMZ import by its format, never as GPX (IO-02)', () => {
const { rerender } = render(
<TourProvenanceSection provenance={{ ...base, origin: 'gpx', source: { kind: 'gpx', fileName: 'Alps.KML' } }} />
);
expect(screen.getByText('tours.provenance.fileImport')).toBeInTheDocument();
expect(screen.queryByText('tours.badge.gpx')).toBeNull();
expect(screen.getByText('Alps.KML')).toBeInTheDocument();
rerender(
<TourProvenanceSection provenance={{ ...base, origin: 'gpx', source: { kind: 'gpx', fileName: null } }} />
);
expect(screen.getByText('tours.badge.gpx')).toBeInTheDocument();
});
it('links a wanderer snapshot to its trail, tells local edits apart and checks the source on purpose', () => {
const onCheck = vi.fn();
render(
@@ -45,12 +45,23 @@ function ProvenanceRow({ icon, label, children }: { icon: ReactNode; label: stri
);
}
/**
* IO-02: a file import keeps the origin `gpx` whatever its format, so a KML or
* KMZ import is named by its stored file name rather than called GPX.
*/
function fileImportLabel(fileName: string | null, t: Translate): string {
const format = fileName?.toLowerCase().split('.').pop();
return format === 'kml' || format === 'kmz'
? t('tours.provenance.fileImport', { format: format.toUpperCase() })
: t('tours.badge.gpx');
}
function SourceValue({ provenance, t }: { provenance: TourProvenance; t: Translate }) {
const { source } = provenance;
if (source.kind === 'gpx') {
return (
<>
<span className="font-semibold">{t('tours.badge.gpx')}</span>
<span className="font-semibold">{fileImportLabel(source.fileName, t)}</span>
{source.fileName && (
<span className="min-w-0 truncate font-geist text-content-muted" style={fs(11.5)}>
{source.fileName}
+43 -19
View File
@@ -1,21 +1,48 @@
import type { TourGpxImportFields, TourGpxPreviewTrack, TourListItem } from '@trek/shared';
import type { TourFileLeftOut, TourGpxImportFields, TourGpxPreviewTrack, TourListItem } from '@trek/shared';
import { useCallback, useState } from 'react';
import { useTranslation } from '../../i18n';
import { tourRepo } from '../../repo/tourRepo';
import { useTripStore } from '../../store/tripStore';
import { getApiErrorMessage } from '../../utils/apiError';
import { getApiErrorCode, getApiErrorMessage } from '../../utils/apiError';
import { useToast } from '../shared/Toast';
/** The server refuses anything larger; saying so first spares the upload. */
const MAX_TOUR_GPX_BYTES = 10 * 1024 * 1024;
const MAX_TOUR_FILE_BYTES = 10 * 1024 * 1024;
/** The files a tour can be imported from: GPX, and since IO-02 KML and the KMZ archive it comes zipped in. */
const TOUR_FILE_EXTENSIONS = ['.gpx', '.kml', '.kmz'];
const NO_TRACK = 'No track or route found in GPX file';
const isGpxFile = (file: Pick<File, 'name'>) => file.name.toLowerCase().endsWith('.gpx');
const isTourFile = (file: Pick<File, 'name'>) =>
TOUR_FILE_EXTENSIONS.some((extension) => file.name.toLowerCase().endsWith(extension));
type Translate = ReturnType<typeof useTranslation>['t'];
/** The toast that names a file the server could not read, by the reason it gave. */
function fileErrorMessage(error: unknown, name: string, t: Translate): string {
switch (getApiErrorCode(error)) {
case 'malformed':
case 'archive_invalid':
return t('tours.import.unreadableIn', { name });
case 'archive_without_kml':
return t('tours.import.archiveNoKmlIn', { name });
case 'archive_too_large':
return t('tours.import.archiveTooLargeIn', { name });
case 'no_track':
return t('tours.import.noTrackIn', { name });
default:
// A GPX file without a track has always been refused by its message alone.
return getApiErrorMessage(error, '') === NO_TRACK
? t('tours.import.noTrackIn', { name })
: t('tours.import.errorIn', { name });
}
}
/** One file as its preview read it: every track or route it holds, each as the import would store it. */
export interface GpxPreviewFile {
file: File;
tracks: TourGpxPreviewTrack[];
/** What a KML or KMZ file held besides its lines, which no tour is made of; absent for GPX. */
leftOut?: TourFileLeftOut;
}
interface TourGpxImportOptions {
@@ -28,8 +55,9 @@ interface TourGpxImportOptions {
}
/**
* The one way a GPX file becomes a tour, whichever control it came through:
* the import button's file picker or a file dropped onto the tours workspace.
* The one way a GPX, KML or KMZ file becomes a tour, whichever control it came
* through: the import button's file picker or a file dropped onto the tours
* workspace.
*
* PAD-02: nothing is stored on choosing a file. Each file is read first and
* shown (its whole course, what it measures, which tracks the trip already
@@ -45,23 +73,18 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported }
const [preview, setPreview] = useState<GpxPreviewFile[] | null>(null);
const fileError = useCallback(
(error: unknown, file: File) =>
toast.error(
getApiErrorMessage(error, '') === NO_TRACK
? t('tours.import.noTrackIn', { name: file.name })
: t('tours.import.errorIn', { name: file.name })
),
(error: unknown, file: File) => toast.error(fileErrorMessage(error, file.name, t)),
[t, toast]
);
const importFiles = useCallback(
async (files: readonly File[]) => {
if (!canEdit || files.length === 0 || importing || preview) return;
const gpx = files.filter(isGpxFile);
if (gpx.length < files.length) toast.error(t('tours.import.unsupported'));
const fitting = gpx.filter((file) => file.size <= MAX_TOUR_GPX_BYTES);
if (fitting.length < gpx.length)
toast.error(t('places.importFileTooLarge', { maxMb: MAX_TOUR_GPX_BYTES / 1024 / 1024 }));
const supported = files.filter(isTourFile);
if (supported.length < files.length) toast.error(t('tours.import.unsupported'));
const fitting = supported.filter((file) => file.size <= MAX_TOUR_FILE_BYTES);
if (fitting.length < supported.length)
toast.error(t('places.importFileTooLarge', { maxMb: MAX_TOUR_FILE_BYTES / 1024 / 1024 }));
if (fitting.length === 0) return;
setImporting(true);
@@ -69,7 +92,8 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported }
try {
for (const file of fitting) {
try {
read.push({ file, tracks: (await tourRepo.previewGpx(tripId, file)).tracks });
const preview = await tourRepo.previewGpx(tripId, file);
read.push({ file, tracks: preview.tracks, leftOut: preview.left_out });
} catch (error: unknown) {
fileError(error, file);
}
@@ -134,7 +158,7 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported }
if (!canEdit || importing || preview) return;
const input = document.createElement('input');
input.type = 'file';
input.accept = '.gpx';
input.accept = TOUR_FILE_EXTENSIONS.join(',');
input.multiple = true;
input.addEventListener('change', () => void importFiles(Array.from(input.files ?? [])), { once: true });
input.click();