mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user