mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +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:
@@ -1,7 +1,7 @@
|
||||
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
|
||||
import { postMultipart } from './client';
|
||||
|
||||
/** A GPX file and the fields beside it, as the Tours import routes read a multipart body. */
|
||||
/** A GPX, KML or KMZ file and the fields beside it, as the Tours import routes read a multipart body. */
|
||||
function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
@@ -9,7 +9,10 @@ function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
||||
return fd;
|
||||
}
|
||||
|
||||
/** The Tours GPX import: a file read for its preview first (PAD-02), then imported as the type chosen. */
|
||||
/**
|
||||
* The Tours file import: a GPX, KML or KMZ file (IO-02) read for its preview
|
||||
* first (PAD-02), then imported as the type chosen.
|
||||
*/
|
||||
export const tourGpxApi = {
|
||||
/** What the file holds, read by the import's own parser; nothing is stored. */
|
||||
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -450,7 +450,7 @@ export const tourRepo = {
|
||||
return result
|
||||
},
|
||||
|
||||
/** Reads a GPX file for its preview; online only, like the import, and nothing is cached. */
|
||||
/** Reads a GPX, KML or KMZ file for its preview; online only, like the import, and nothing is cached. */
|
||||
async previewGpx(tripId: number | string, file: File): Promise<TourGpxPreviewResponse> {
|
||||
return tourGpxApi.preview(tripId, file)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FE-W4UTL-001 to FE-W4UTL-006
|
||||
// FE-W4UTL-001 to FE-W4UTL-008
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getApiErrorMessage } from './apiError'
|
||||
import { getApiErrorCode, getApiErrorMessage } from './apiError'
|
||||
|
||||
describe('getApiErrorMessage', () => {
|
||||
it('FE-W4UTL-001: returns the server-provided error string', () => {
|
||||
@@ -33,3 +33,17 @@ describe('getApiErrorMessage', () => {
|
||||
expect(getApiErrorMessage({ response: { data: { error: ' boom ' } } }, 'fallback')).toBe(' boom ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getApiErrorCode', () => {
|
||||
it('FE-W4UTL-007: returns the code a refusal names its reason with', () => {
|
||||
expect(getApiErrorCode({ response: { data: { error: 'Invalid KMZ archive.', code: 'archive_invalid' } } })).toBe('archive_invalid')
|
||||
})
|
||||
|
||||
it('FE-W4UTL-008: is null without a code, for an empty or non-string one, and for no error at all', () => {
|
||||
expect(getApiErrorCode({ response: { data: { error: 'boom' } } })).toBeNull()
|
||||
expect(getApiErrorCode({ response: { data: { code: '' } } })).toBeNull()
|
||||
expect(getApiErrorCode({ response: { data: { code: 42 } } })).toBeNull()
|
||||
expect(getApiErrorCode(null)).toBeNull()
|
||||
expect(getApiErrorCode(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,3 +7,9 @@ export function getApiErrorMessage(err: unknown, fallback: string): string {
|
||||
const message = (err as { response?: { data?: { error?: unknown } } })?.response?.data?.error
|
||||
return typeof message === 'string' && message.trim() ? message : fallback
|
||||
}
|
||||
|
||||
/** The `code` a refusal names its reason with, beside its message; null when it carries none. */
|
||||
export function getApiErrorCode(err: unknown): string | null {
|
||||
const code = (err as { response?: { data?: { code?: unknown } } } | null)?.response?.data?.code
|
||||
return typeof code === 'string' && code ? code : null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The names an import gives the lines of one file. Routes and tracks rarely
|
||||
* carry their own name. Without one they would all fall back to the same
|
||||
* generic label, so name-based dedup would drop every import after the first.
|
||||
* The base is the source file's name (without its extension) and an index
|
||||
* keeps several unnamed lines of one file distinct: `walk`, `walk 2`, `walk 3`.
|
||||
*/
|
||||
type GeometryNamer = (explicit: string | null, fallback: string) => string;
|
||||
|
||||
export function createGeometryNamer(defaultName: string | null | undefined): GeometryNamer {
|
||||
const rawName = defaultName != null ? String(defaultName).trim() : null;
|
||||
const baseName = rawName ? rawName.replace(/\.[^.]+$/, '').trim() || rawName : null;
|
||||
let unnamed = 0;
|
||||
return (explicit, fallback) => {
|
||||
if (explicit) return explicit;
|
||||
unnamed++;
|
||||
const base = baseName || fallback;
|
||||
return unnamed === 1 ? base : `${base} ${unnamed}`;
|
||||
};
|
||||
}
|
||||
@@ -8,12 +8,24 @@
|
||||
* either a point or a path's first point plus the path as route geometry.
|
||||
* Which placemarks become places, and under which category, is the importing
|
||||
* domain's decision.
|
||||
*
|
||||
* The Tours import reads the same document for its lines instead (IO-02):
|
||||
* every LineString and gx:Track, also inside a MultiGeometry or gx:MultiTrack,
|
||||
* as a line of its own, and a count of what it leaves out.
|
||||
*/
|
||||
import { stripHtmlTags } from '../common/stripHtmlTags';
|
||||
import type { KmlDocumentRead, KmlImportSummary, ParsedKmlPlacemark } from './place-import.types';
|
||||
import {
|
||||
KmlReadError,
|
||||
type KmlDocumentRead,
|
||||
type KmlImportSummary,
|
||||
type KmlLeftOut,
|
||||
type KmlLineRead,
|
||||
type KmlLinesRead,
|
||||
type ParsedKmlPlacemark,
|
||||
} from './place-import.types';
|
||||
|
||||
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import unzipper from 'unzipper';
|
||||
import unzipper, { type File as ZipEntry } from 'unzipper';
|
||||
import { TextDecoder } from 'util';
|
||||
|
||||
export type { ParsedKmlPlacemark } from './place-import.types';
|
||||
@@ -52,6 +64,15 @@ function asArray<T>(value: T | T[] | null | undefined): T[] {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/** One parsed element: its children and attributes by name. */
|
||||
type KmlNode = Readonly<Record<string, unknown>>;
|
||||
|
||||
const isKmlNode = (value: unknown): value is KmlNode =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/** A child of a parsed element by name; nothing for text or an empty element. */
|
||||
const field = (value: unknown, name: string): unknown => (isKmlNode(value) ? value[name] : undefined);
|
||||
|
||||
function asTrimmedString(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
// Parsed objects (mixed-content XML parsed without stopNodes) must not
|
||||
@@ -111,7 +132,9 @@ export function sanitizeKmlDescription(value: unknown): string | null {
|
||||
return decoded || null;
|
||||
}
|
||||
|
||||
function parseKmlLineStringCoordinates(value: unknown): Array<{ lat: number; lng: number; ele: number | null }> | null {
|
||||
type LinePoint = { lat: number; lng: number; ele: number | null };
|
||||
|
||||
function parseKmlLineStringCoordinates(value: unknown): LinePoint[] | null {
|
||||
const coordinates = asTrimmedString(value);
|
||||
if (!coordinates) return null;
|
||||
|
||||
@@ -126,7 +149,7 @@ function parseKmlLineStringCoordinates(value: unknown): Array<{ lat: number; lng
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
return { lat, lng, ele: Number.isFinite(eleRaw) ? eleRaw : null };
|
||||
})
|
||||
.filter((p): p is { lat: number; lng: number; ele: number | null } => p !== null);
|
||||
.filter((p): p is LinePoint => p !== null);
|
||||
|
||||
return points.length >= 2 ? points : null;
|
||||
}
|
||||
@@ -156,19 +179,15 @@ function createKmlImportSummary(totalPlacemarks: number): KmlImportSummary {
|
||||
};
|
||||
}
|
||||
|
||||
export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] {
|
||||
const nodes: KmlPlacemarkNode[] = [];
|
||||
|
||||
const visitNode = (node: any, currentFolderName: string | null): void => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
for (const placemark of asArray(node.Placemark)) {
|
||||
nodes.push({ placemark, folderName: currentFolderName });
|
||||
}
|
||||
/** Every container of a document (the root, its Documents and Folders), each before its own, with its folder name. */
|
||||
function visitKmlContainers(kmlRoot: unknown, visit: (node: KmlNode, folderName: string | null) => void): void {
|
||||
const visitNode = (node: unknown, currentFolderName: string | null): void => {
|
||||
if (!isKmlNode(node)) return;
|
||||
visit(node, currentFolderName);
|
||||
|
||||
for (const folder of asArray(node.Folder)) {
|
||||
// Nested folders inherit/override folder context used for category matching.
|
||||
const folderName = asTrimmedString(folder?.name) || currentFolderName;
|
||||
const folderName = asTrimmedString(field(folder, 'name')) || currentFolderName;
|
||||
visitNode(folder, folderName);
|
||||
}
|
||||
|
||||
@@ -178,6 +197,15 @@ export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] {
|
||||
};
|
||||
|
||||
visitNode(kmlRoot, null);
|
||||
}
|
||||
|
||||
export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] {
|
||||
const nodes: KmlPlacemarkNode[] = [];
|
||||
visitKmlContainers(kmlRoot, (node, folderName) => {
|
||||
for (const placemark of asArray(node.Placemark)) {
|
||||
nodes.push({ placemark, folderName });
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
@@ -206,39 +234,165 @@ export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark {
|
||||
}
|
||||
|
||||
/**
|
||||
* A KML document as its placemarks (each level's own before its folders'), with the summary the
|
||||
* import fills in. Throws the two messages the import route has always
|
||||
* answered for a broken file.
|
||||
* A KML file as its parsed root element. Throws the two messages the import
|
||||
* route has always answered for a broken file.
|
||||
*/
|
||||
export function readKmlDocument(fileBuffer: Buffer): KmlDocumentRead {
|
||||
function parseKmlRoot(fileBuffer: Buffer): { kmlRoot: KmlNode; warning: string | null } {
|
||||
const decoded = decodeUtf8WithWarning(fileBuffer);
|
||||
|
||||
const validationResult = XMLValidator.validate(decoded.text);
|
||||
if (validationResult !== true) {
|
||||
throw new Error('Malformed KML: invalid XML structure');
|
||||
throw new KmlReadError('malformed', 'Malformed KML: invalid XML structure');
|
||||
}
|
||||
|
||||
const parsed = kmlParser.parse(decoded.text);
|
||||
const kmlRoot = parsed?.kml ?? parsed;
|
||||
const parsed: unknown = kmlParser.parse(decoded.text);
|
||||
const kmlRoot = field(parsed, 'kml') ?? parsed;
|
||||
|
||||
if (!kmlRoot || typeof kmlRoot !== 'object') {
|
||||
throw new Error('Malformed KML: could not parse XML');
|
||||
if (!isKmlNode(kmlRoot)) {
|
||||
throw new KmlReadError('malformed', 'Malformed KML: could not parse XML');
|
||||
}
|
||||
|
||||
return { kmlRoot, warning: decoded.warning };
|
||||
}
|
||||
|
||||
/**
|
||||
* A KML document as its placemarks (each level's own before its folders'), with the summary the
|
||||
* import fills in.
|
||||
*/
|
||||
export function readKmlDocument(fileBuffer: Buffer): KmlDocumentRead {
|
||||
const { kmlRoot, warning } = parseKmlRoot(fileBuffer);
|
||||
|
||||
const placemarkNodes = extractKmlPlacemarkNodes(kmlRoot);
|
||||
const summary = createKmlImportSummary(placemarkNodes.length);
|
||||
|
||||
if (decoded.warning) {
|
||||
summary.warnings.push(decoded.warning);
|
||||
if (warning) {
|
||||
summary.warnings.push(warning);
|
||||
}
|
||||
|
||||
return { placemarks: placemarkNodes.map(parsePlacemarkNode), summary };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lines for the Tours import (IO-02)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** What a document holds besides placemarks that the Tours import never reads: overlays, and links it never follows. */
|
||||
const LEFT_OUT_FEATURES = ['NetworkLink', 'GroundOverlay', 'ScreenOverlay', 'PhotoOverlay'] as const;
|
||||
|
||||
/** The points of a gx:Track, each `<gx:coord>` written `lng lat alt` with spaces. */
|
||||
function parseKmlTrackCoordinates(value: unknown): LinePoint[] | null {
|
||||
const points = asArray(value).flatMap((coord): LinePoint[] => {
|
||||
const [lng = Number.NaN, lat = Number.NaN, ele = Number.NaN] = (asTrimmedString(coord) ?? '')
|
||||
.split(/\s+/)
|
||||
.map((part) => Number.parseFloat(part));
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return [];
|
||||
return [{ lat, lng, ele: Number.isFinite(ele) ? ele : null }];
|
||||
});
|
||||
return points.length >= 2 ? points : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A line as route geometry, `[lat, lng, ele]` only where its heights can be
|
||||
* trusted as heights. KML's default altitude mode is clampToGround, under
|
||||
* which an altitude means nothing, and Google My Maps writes a 0 for every
|
||||
* point. So the heights are kept when the line says `absolute`, dropped when
|
||||
* it names any other mode, and without a mode kept unless every one is 0. A
|
||||
* line with a point lacking one keeps none, as a GPX track does.
|
||||
*/
|
||||
export function kmlLineGeometry(points: LinePoint[], altitudeMode: string | null): number[][] {
|
||||
const complete = points.every((p) => p.ele !== null);
|
||||
const meaningful = altitudeMode === null ? points.some((p) => p.ele !== 0) : altitudeMode === 'absolute';
|
||||
return points.map((p) => (complete && meaningful && p.ele !== null ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]));
|
||||
}
|
||||
|
||||
/** One geometry element of a placemark taken apart into its lines; what is not a line is counted as left out. */
|
||||
function collectKmlLines(node: KmlNode, inheritedMode: string | null, lines: number[][][], leftOut: KmlLeftOut): void {
|
||||
const mode = asTrimmedString(node.altitudeMode) ?? inheritedMode;
|
||||
const shapes = [
|
||||
...asArray(node.LineString).map((line) => ({
|
||||
line,
|
||||
points: parseKmlLineStringCoordinates(field(line, 'coordinates')),
|
||||
})),
|
||||
...asArray(node.Track).map((line) => ({ line, points: parseKmlTrackCoordinates(field(line, 'coord')) })),
|
||||
];
|
||||
for (const { line, points } of shapes) {
|
||||
if (points) lines.push(kmlLineGeometry(points, asTrimmedString(field(line, 'altitudeMode')) ?? mode));
|
||||
else leftOut.other += 1;
|
||||
}
|
||||
for (const group of [...asArray(node.MultiGeometry), ...asArray(node.MultiTrack)]) {
|
||||
if (isKmlNode(group)) collectKmlLines(group, mode, lines, leftOut);
|
||||
}
|
||||
leftOut.points += asArray(node.Point).length;
|
||||
leftOut.areas += asArray(node.Polygon).length;
|
||||
leftOut.other += asArray(node.Model).length + asArray(node.LinearRing).length;
|
||||
}
|
||||
|
||||
const leftOutTotal = (leftOut: KmlLeftOut) => leftOut.points + leftOut.areas + leftOut.other;
|
||||
|
||||
/**
|
||||
* A placemark's lines, each kept apart: two lines of one placemark are never
|
||||
* joined into one, they are numbered after its name instead.
|
||||
*/
|
||||
function placemarkLines(placemark: unknown, leftOut: KmlLeftOut): KmlLineRead[] {
|
||||
const geometries: number[][][] = [];
|
||||
const before = leftOutTotal(leftOut);
|
||||
if (isKmlNode(placemark)) collectKmlLines(placemark, null, geometries, leftOut);
|
||||
// A placemark that held nothing at all, not even something left out, is counted once.
|
||||
if (geometries.length === 0 && leftOutTotal(leftOut) === before) leftOut.other += 1;
|
||||
|
||||
const name = asTrimmedString(field(placemark, 'name'));
|
||||
const description = sanitizeKmlDescription(field(placemark, 'description'));
|
||||
return geometries.map((geometry, index) => ({
|
||||
name: name && geometries.length > 1 ? `${name} (${index + 1}/${geometries.length})` : name,
|
||||
description,
|
||||
geometry,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A KML document as the lines a tour can be made of, in document order, and a
|
||||
* count of what it holds besides: single points, areas, and everything else
|
||||
* (models, overlays, links to other files, which are never followed, and
|
||||
* lines too short to be one).
|
||||
*/
|
||||
export function readKmlLines(fileBuffer: Buffer): KmlLinesRead {
|
||||
const { kmlRoot, warning } = parseKmlRoot(fileBuffer);
|
||||
const leftOut: KmlLeftOut = { points: 0, areas: 0, other: 0 };
|
||||
const lines = extractKmlPlacemarkNodes(kmlRoot).flatMap(({ placemark }) => placemarkLines(placemark, leftOut));
|
||||
visitKmlContainers(kmlRoot, (node) => {
|
||||
for (const feature of LEFT_OUT_FEATURES) leftOut.other += asArray(node[feature]).length;
|
||||
});
|
||||
return { lines, leftOut, warnings: warning ? [warning] : [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KMZ unpacking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tooLarge = () =>
|
||||
new KmlReadError('archive_too_large', 'KMZ archive exceeds the maximum allowed decompressed size.');
|
||||
|
||||
/**
|
||||
* An archive entry decompressed, stopped as soon as it passes the limit. The
|
||||
* size an entry's header states is only a claim, so the bytes themselves are
|
||||
* counted: a crafted archive cannot unpack more than the limit by lying.
|
||||
*/
|
||||
export async function readKmzEntryCapped(entry: Pick<ZipEntry, 'stream'>, limit: number): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of entry.stream()) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
||||
size += bytes.length;
|
||||
if (size > limit) throw tooLarge();
|
||||
chunks.push(bytes);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* The KML a KMZ archive carries. Only that one entry is read: images and
|
||||
* other files beside it are never unpacked, run or fetched.
|
||||
*/
|
||||
export async function unpackKmzToKml(
|
||||
kmzBuffer: Buffer,
|
||||
decompressedSizeLimit = KMZ_DECOMPRESSED_SIZE_LIMIT,
|
||||
@@ -247,21 +401,27 @@ export async function unpackKmzToKml(
|
||||
try {
|
||||
zip = await unzipper.Open.buffer(kmzBuffer);
|
||||
} catch {
|
||||
throw new Error('Invalid KMZ archive.');
|
||||
throw new KmlReadError('archive_invalid', 'Invalid KMZ archive.');
|
||||
}
|
||||
|
||||
const kmlEntries = zip.files.filter(
|
||||
(entry) => !entry.path.endsWith('/') && entry.path.toLowerCase().endsWith('.kml'),
|
||||
);
|
||||
if (kmlEntries.length === 0) {
|
||||
throw new Error('KMZ archive does not contain a KML file.');
|
||||
throw new KmlReadError('archive_without_kml', 'KMZ archive does not contain a KML file.');
|
||||
}
|
||||
|
||||
const preferredEntry = kmlEntries.find((entry) => entry.path.toLowerCase().endsWith('doc.kml')) || kmlEntries[0];
|
||||
|
||||
if (preferredEntry.uncompressedSize > decompressedSizeLimit) {
|
||||
throw new Error('KMZ archive exceeds the maximum allowed decompressed size.');
|
||||
throw tooLarge();
|
||||
}
|
||||
|
||||
return preferredEntry.buffer();
|
||||
try {
|
||||
return await readKmzEntryCapped(preferredEntry, decompressedSizeLimit);
|
||||
} catch (err) {
|
||||
if (err instanceof KmlReadError) throw err;
|
||||
// A damaged entry fails while it inflates, not while the directory is read.
|
||||
throw new KmlReadError('archive_invalid', 'Invalid KMZ archive.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
GoogleListPlace,
|
||||
GpxImportOptions,
|
||||
KmlDocumentRead,
|
||||
KmlTracksRead,
|
||||
ListImportError,
|
||||
ListRead,
|
||||
NaverListPlace,
|
||||
@@ -109,6 +110,11 @@ export class PlaceImportService {
|
||||
return this.kml.readKml(fileBuffer);
|
||||
}
|
||||
|
||||
/** IO-02: the lines of a .kml or .kmz upload as tour rows, and what it holds besides. Throws KmlReadError. */
|
||||
readMapFileTracks(fileBuffer: Buffer, filename: string): Promise<KmlTracksRead> {
|
||||
return this.kml.readTracks(fileBuffer, filename);
|
||||
}
|
||||
|
||||
// ── Shared lists ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A Google Maps list, or the directions link a short link turned out to be. */
|
||||
|
||||
@@ -56,6 +56,53 @@ export interface KmlDocumentRead {
|
||||
summary: KmlImportSummary;
|
||||
}
|
||||
|
||||
/** What a KML document holds that a line import leaves out, counted by kind. */
|
||||
export interface KmlLeftOut {
|
||||
/** Single points (pins). */
|
||||
points: number;
|
||||
/** Polygons. */
|
||||
areas: number;
|
||||
/** Everything else: models, overlays, links to other files, placemarks without a geometry, too short lines. */
|
||||
other: number;
|
||||
}
|
||||
|
||||
/** One line of a KML document: a LineString or a gx:Track, as route geometry `[lat, lng]` or `[lat, lng, ele]`. */
|
||||
export interface KmlLineRead {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
geometry: number[][];
|
||||
}
|
||||
|
||||
/** A KML document read for its lines (IO-02). */
|
||||
export interface KmlLinesRead {
|
||||
lines: KmlLineRead[];
|
||||
leftOut: KmlLeftOut;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** The lines of a KML or KMZ file as the rows a Tours import stores, and what it leaves out. */
|
||||
export interface KmlTracksRead {
|
||||
tracks: PreparedGpxPlace[];
|
||||
leftOut: KmlLeftOut;
|
||||
}
|
||||
|
||||
/** Why a KML or KMZ file could not be read. */
|
||||
export type KmlReadFailure = 'malformed' | 'archive_invalid' | 'archive_without_kml' | 'archive_too_large';
|
||||
|
||||
/**
|
||||
* A KML or KMZ file that could not be read, with the message the place import
|
||||
* route has always answered and the reason a caller can tell apart.
|
||||
*/
|
||||
export class KmlReadError extends Error {
|
||||
constructor(
|
||||
readonly reason: KmlReadFailure,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'KmlReadError';
|
||||
}
|
||||
}
|
||||
|
||||
/** An import that could not happen, with the status and the exact message the route answers. */
|
||||
export interface ListImportError {
|
||||
error: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createGeometryNamer } from '../geometry-names';
|
||||
import { coord, gpxGeometryParser, writeGpx } from '../gpx.codec';
|
||||
import type { GpxImportOptions, PreparedGpxPlace } from '../place-import.types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@@ -249,19 +250,7 @@ function prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}): Prepar
|
||||
return Number.isNaN(n) ? null : n;
|
||||
};
|
||||
|
||||
// Routes and tracks rarely carry their own <name>. Without one they all fall back to the
|
||||
// same generic label, so name-based dedup drops every import after the first. Derive a
|
||||
// base from the source filename (the requested behaviour) and suffix an index so multiple
|
||||
// geometries from one file stay distinct.
|
||||
const rawName = str(defaultName);
|
||||
const baseName = rawName ? rawName.replace(/\.[^.]+$/, '').trim() || rawName : null;
|
||||
let geoSeq = 0;
|
||||
const geoName = (explicit: string | null, fallback: string): string => {
|
||||
if (explicit) return explicit;
|
||||
geoSeq++;
|
||||
const base = baseName || fallback;
|
||||
return geoSeq === 1 ? base : `${base} ${geoSeq}`;
|
||||
};
|
||||
const geoName = createGeometryNamer(defaultName);
|
||||
|
||||
const waypoints: PreparedGpxPlace[] = [];
|
||||
|
||||
|
||||
@@ -2,15 +2,22 @@
|
||||
* A KML or KMZ map file (Google My Maps, Google Earth), read through kml.codec
|
||||
* into its placemarks. The file type is the upload's extension, as it has
|
||||
* always been; anything else is refused with the route's own message.
|
||||
*
|
||||
* The Tours import reads the same file for its lines (IO-02): each one a row
|
||||
* the GPX import's persistence takes as it takes a track, named the way a GPX
|
||||
* track without a name is.
|
||||
*/
|
||||
import { readKmlDocument, unpackKmzToKml } from '../kml.codec';
|
||||
import type { KmlDocumentRead } from '../place-import.types';
|
||||
import { createGeometryNamer } from '../geometry-names';
|
||||
import { readKmlDocument, readKmlLines, unpackKmzToKml } from '../kml.codec';
|
||||
import type { KmlDocumentRead, KmlTracksRead, PreparedGpxPlace } from '../place-import.types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
const extensionOf = (filename: string) => filename.toLowerCase().split('.').pop();
|
||||
|
||||
@Injectable()
|
||||
export class KmlProvider {
|
||||
async readMapFile(fileBuffer: Buffer, filename: string): Promise<KmlDocumentRead> {
|
||||
const ext = filename.toLowerCase().split('.').pop();
|
||||
const ext = extensionOf(filename);
|
||||
if (ext === 'kmz') return this.readKmz(fileBuffer);
|
||||
if (ext === 'kml') return this.readKml(fileBuffer);
|
||||
throw new Error(`Unsupported map file format: .${ext}. Please upload a .kml or .kmz file.`);
|
||||
@@ -23,4 +30,23 @@ export class KmlProvider {
|
||||
readKml(fileBuffer: Buffer): KmlDocumentRead {
|
||||
return readKmlDocument(fileBuffer);
|
||||
}
|
||||
|
||||
/** The lines of a .kml or, by its extension, a .kmz file as tour rows, and what the file holds besides. */
|
||||
async readTracks(fileBuffer: Buffer, filename: string): Promise<KmlTracksRead> {
|
||||
const kml = extensionOf(filename) === 'kmz' ? await unpackKmzToKml(fileBuffer) : fileBuffer;
|
||||
const { lines, leftOut } = readKmlLines(kml);
|
||||
const nameOf = createGeometryNamer(filename);
|
||||
const tracks = lines.map((line): PreparedGpxPlace => {
|
||||
// A line has at least two points, so its start is always there.
|
||||
const [lat, lng] = line.geometry[0];
|
||||
return {
|
||||
name: nameOf(line.name, 'KML Track'),
|
||||
lat,
|
||||
lng,
|
||||
description: line.description,
|
||||
routeGeometry: JSON.stringify(line.geometry),
|
||||
};
|
||||
});
|
||||
return { tracks, leftOut };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* IO-02: the file a Tours import reads, whichever of the three formats it is.
|
||||
* A GPX file gives its tracks and routes, a KML or KMZ file its lines; both
|
||||
* come back as the same rows, so the preview, the duplicate rule and the one
|
||||
* transaction that stores them are the GPX import's own. Loose points are
|
||||
* never a tour: a GPX file's waypoints are skipped as they always were, and a
|
||||
* KML file's pins, areas and the rest are counted for the preview to name.
|
||||
*/
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import type { PlaceImportService } from '../place-import/place-import.service';
|
||||
import { KmlReadError, type PreparedGpxPlace } from '../place-import/place-import.types';
|
||||
import type { TourFileLeftOut } from '@trek/shared';
|
||||
|
||||
type TourFileFormat = 'gpx' | 'kml' | 'kmz';
|
||||
|
||||
/** A file read for a Tours import: the rows it would store, and for a KML or KMZ file what it holds besides. */
|
||||
export interface TourFileRead {
|
||||
rows: PreparedGpxPlace[];
|
||||
leftOut?: TourFileLeftOut;
|
||||
}
|
||||
|
||||
/** The format by the upload's extension; anything else is read as GPX, as every Tours import was before. */
|
||||
function tourFileFormat(fileName: string | undefined): TourFileFormat {
|
||||
const ext = fileName?.toLowerCase().split('.').pop();
|
||||
return ext === 'kml' || ext === 'kmz' ? ext : 'gpx';
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal for a file without a single line. A GPX file is answered with
|
||||
* the body the route has always sent; a KML or KMZ file names its own format
|
||||
* and carries the code the client tells the case by.
|
||||
*/
|
||||
export function noTrackFound(fileName: string | undefined): DomainError {
|
||||
const format = tourFileFormat(fileName);
|
||||
if (format === 'gpx') return new DomainError(400, 'No track or route found in GPX file', { code: 'no_track' });
|
||||
return new DomainError(400, `No line found in ${format.toUpperCase()} file`, {
|
||||
code: 'no_track',
|
||||
details: { code: 'no_track' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a Tours import stores from this file. A KML or KMZ file that cannot
|
||||
* be read is a 400 whose `code` says why (malformed, archive_invalid,
|
||||
* archive_without_kml, archive_too_large).
|
||||
*/
|
||||
export async function readTourFile(
|
||||
placeImport: PlaceImportService,
|
||||
fileBuffer: Buffer,
|
||||
fileName: string | undefined,
|
||||
): Promise<TourFileRead> {
|
||||
if (tourFileFormat(fileName) === 'gpx') {
|
||||
const rows = placeImport.readGpx(fileBuffer, {
|
||||
importWaypoints: false,
|
||||
importRoutes: true,
|
||||
importTracks: true,
|
||||
defaultName: fileName,
|
||||
});
|
||||
return { rows };
|
||||
}
|
||||
try {
|
||||
const { tracks, leftOut } = await placeImport.readMapFileTracks(fileBuffer, fileName ?? '');
|
||||
return { rows: tracks, leftOut };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof KmlReadError) {
|
||||
throw new DomainError(400, err.message, { code: 'tour_file_unreadable', details: { code: err.reason } });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { TourGpxImportFieldsDto } from './dto/tour-gpx-import.dto';
|
||||
import { noTrackFound } from './tour-file-import.helpers';
|
||||
import { ToursService } from './tours.service';
|
||||
import {
|
||||
Body,
|
||||
@@ -25,11 +26,12 @@ import { tourGpxPreviewResponseSchema, tourImportGpxResponseSchema } from '@trek
|
||||
import { memoryStorage } from 'multer';
|
||||
|
||||
const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } };
|
||||
const NO_TRACK = 'No track or route found in GPX file';
|
||||
|
||||
/**
|
||||
* POST /api/trips/:tripId/tours/import/gpx: the tours-mode GPX import, and
|
||||
* POST /api/trips/:tripId/tours/import/gpx: the tours-mode file import, and
|
||||
* POST .../import/gpx/preview: the same file read and shown, nothing stored.
|
||||
* Both read a GPX file, or by its extension a KML or KMZ file (IO-02); the
|
||||
* paths keep the name they were published under.
|
||||
*
|
||||
* A controller of its own because it must not sit behind ToursController's
|
||||
* AddonGuard and TripAccessGuard: a guard answers before multer has read the
|
||||
@@ -78,7 +80,7 @@ export class ToursImportController {
|
||||
const buffer = await this.checkedFile(user, tripId, file);
|
||||
const preview = await this.tours.previewGpx(tripId, buffer, file?.originalname);
|
||||
if (!preview) {
|
||||
throw new HttpException({ error: NO_TRACK }, 400);
|
||||
throw noTrackFound(file?.originalname);
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
@@ -97,7 +99,7 @@ export class ToursImportController {
|
||||
const buffer = await this.checkedFile(user, tripId, file);
|
||||
const result = await this.tours.importGpxAsTour(tripId, buffer, file?.originalname, socketId, fields);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: NO_TRACK }, 400);
|
||||
throw noTrackFound(file?.originalname);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'list_tours',
|
||||
description:
|
||||
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).',
|
||||
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx for an imported GPX, KML or KMZ file, or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PlaceImportService } from '../place-import/place-import.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { activityFacts, toActivity, trackTimesFor } from './tour-activities.helpers';
|
||||
import { readTourFile } from './tour-file-import.helpers';
|
||||
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
import { advanceTourRevision } from './tour-revision';
|
||||
import { toVisit } from './tour-visits.service';
|
||||
@@ -88,7 +89,7 @@ export interface ImportGpxAsTourResult {
|
||||
/**
|
||||
* Tours domain: the `tours` facet table (place_id PK/FK) carries tour-specific
|
||||
* metadata while existing places and day assignments are reused.
|
||||
* GPX parsing goes through PlaceImportService.readGpx, persistence through
|
||||
* A GPX, KML or KMZ file is read through PlaceImportService (readTourFile), persistence goes through
|
||||
* PlacesService.importPreparedGpx. Places and facet rows are persisted in one transaction,
|
||||
* with tour metrics derived from each place's route_geometry.
|
||||
*/
|
||||
@@ -949,10 +950,11 @@ export class ToursService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The tours-mode GPX import reads rows through PlaceImportService.readGpx
|
||||
* with waypoints excluded. PlacesService.importPreparedGpx persists the places
|
||||
* in the same transaction as their `tours` facet rows, whose metrics are
|
||||
* derived from route_geometry.
|
||||
* The tours-mode file import reads rows through readTourFile: a GPX file's
|
||||
* tracks and routes with its waypoints excluded, a KML or KMZ file's lines
|
||||
* (IO-02). PlacesService.importPreparedGpx persists the places in the same
|
||||
* transaction as their `tours` facet rows, whose metrics are derived from
|
||||
* route_geometry, so a failure leaves no tour half stored.
|
||||
*
|
||||
* Missing or implausible metrics do not block import:
|
||||
* a track without elevation still imports, just flagged with a low
|
||||
@@ -965,7 +967,7 @@ export class ToursService {
|
||||
socketId?: string,
|
||||
fields: TourGpxImportFields = HIKE_IMPORT,
|
||||
): Promise<ImportGpxAsTourResult | null> {
|
||||
const rows = this.readGpxTracks(fileBuffer, fileName);
|
||||
const { rows } = await readTourFile(this.placeImport, fileBuffer, fileName);
|
||||
if (rows.length === 0) return null;
|
||||
await this.assertTourTypeEnabled(fields.tour_type);
|
||||
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water,
|
||||
@@ -1004,7 +1006,7 @@ export class ToursService {
|
||||
match_confidence: matchConfidence,
|
||||
max_hiking_difficulty: null,
|
||||
origin: 'gpx',
|
||||
origin_file_name: gpxFileName(fileName),
|
||||
origin_file_name: importFileName(fileName),
|
||||
track_started_at: recorded?.started ?? null,
|
||||
track_ended_at: recorded?.ended ?? null,
|
||||
});
|
||||
@@ -1063,12 +1065,13 @@ export class ToursService {
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-02: what a GPX file would import, read with the import's own parser
|
||||
* and duplicate rule and written nowhere. Null when the file holds no track
|
||||
* or route, the import's own refusal.
|
||||
* PAD-02: what a file would import, read with the import's own parser and
|
||||
* duplicate rule and written nowhere. Null when the file holds no track or
|
||||
* route, the import's own refusal. A KML or KMZ file also says what it holds
|
||||
* besides its lines (IO-02).
|
||||
*/
|
||||
async previewGpx(tripId: string, fileBuffer: Buffer, fileName?: string): Promise<TourGpxPreviewResponse | null> {
|
||||
const rows = this.readGpxTracks(fileBuffer, fileName);
|
||||
const { rows, leftOut } = await readTourFile(this.placeImport, fileBuffer, fileName);
|
||||
if (rows.length === 0) return null;
|
||||
const duplicates = await this.places.gpxDuplicates(tripId, rows);
|
||||
return {
|
||||
@@ -1087,18 +1090,9 @@ export class ToursService {
|
||||
duplicate: duplicates[index] ?? false,
|
||||
};
|
||||
}),
|
||||
...(leftOut ? { left_out: leftOut } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The tracks and routes of a GPX file, never its loose waypoints: the reading every Tours import shares. */
|
||||
private readGpxTracks(fileBuffer: Buffer, fileName?: string) {
|
||||
return this.placeImport.readGpx(fileBuffer, {
|
||||
importWaypoints: false,
|
||||
importRoutes: true,
|
||||
importTracks: true,
|
||||
defaultName: fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a tour without a line starts (CW-11): at its first point. */
|
||||
@@ -1107,8 +1101,8 @@ function firstPointOf(points: readonly { lat: number; lng: number }[]): [number,
|
||||
return point ? [point.lat, point.lng] : undefined;
|
||||
}
|
||||
|
||||
/** The file name a GPX import keeps: the name as uploaded, without a path, cut to what the column shows. */
|
||||
function gpxFileName(name: string | undefined): string | null {
|
||||
/** The file name a file import keeps: the name as uploaded, without a path, cut to what the column shows. */
|
||||
function importFileName(name: string | undefined): string | null {
|
||||
const base = name?.split('/').pop()?.trim();
|
||||
return base ? base.slice(0, 255) : null;
|
||||
}
|
||||
|
||||
@@ -46,8 +46,10 @@ import { keyed } from '../helpers/tour-keys';
|
||||
import { sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import archiver from 'archiver';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { PassThrough } from 'stream';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi, type MockInstance } from 'vitest';
|
||||
|
||||
@@ -1032,6 +1034,107 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('KML and KMZ preview and import (IO-02)', () => {
|
||||
const MAP_KML = `<?xml version="1.0"?><kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||
<Placemark><name>Coast walk</name><LineString><coordinates>12,49,0 12.01,49.01,0 12.02,49.02,0</coordinates>
|
||||
</LineString></Placemark>
|
||||
<Placemark><name>Summit</name><LineString><altitudeMode>absolute</altitudeMode>
|
||||
<coordinates>13,50,100 13.01,50.01,180 13.02,50.02,150</coordinates></LineString></Placemark>
|
||||
<Placemark><name>Hut</name><Point><coordinates>12,49</coordinates></Point></Placemark>
|
||||
<Placemark><name>Lake</name><Polygon><outerBoundaryIs><LinearRing>
|
||||
<coordinates>1,1 2,2 3,1 1,1</coordinates></LinearRing></outerBoundaryIs></Polygon></Placemark>
|
||||
<NetworkLink><Link><href>https://example.test/more.kml</href></Link></NetworkLink>
|
||||
</Document></kml>`;
|
||||
|
||||
/** A KMZ archive holding these entries. */
|
||||
async function kmz(entries: Record<string, string>): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
const output = new PassThrough();
|
||||
output.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
const archive = archiver('zip', { zlib: { level: 1 } });
|
||||
archive.pipe(output);
|
||||
for (const [name, content] of Object.entries(entries)) archive.append(Buffer.from(content), { name });
|
||||
await archive.finalize();
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
const upload = (
|
||||
bytes: Buffer,
|
||||
filename: string,
|
||||
path = 'import/gpx/preview',
|
||||
fields: Record<string, string> = {},
|
||||
) => {
|
||||
let req = request(server).post(`/api/trips/5/tours/${path}`).set('Cookie', sessionCookie(OWNER));
|
||||
for (const [key, value] of Object.entries(fields)) req = req.field(key, value);
|
||||
return req.attach('file', bytes, { filename, contentType: 'application/octet-stream' });
|
||||
};
|
||||
|
||||
it('shows each line apart with the heights it has, counts what it leaves out, and stores nothing', async () => {
|
||||
const res = await upload(Buffer.from(MAP_KML), 'alps.kml');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tracks.map((track: { name: string }) => track.name)).toEqual(['Coast walk', 'Summit']);
|
||||
// Google My Maps writes 0 for every height: that is no height, not a flat walk.
|
||||
expect(res.body.tracks[0]).toMatchObject({ point_count: 3, elevation_gain_m: null, elevation_loss_m: null });
|
||||
expect(res.body.tracks[1]).toMatchObject({ point_count: 3, elevation_gain_m: 80, elevation_loss_m: 30 });
|
||||
expect(res.body.left_out).toEqual({ points: 1, areas: 1, other: 1 });
|
||||
expect(await countRows(orm, Places)).toBe(0);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('imports the lines as tours of the chosen type, keeps the file name, skips them the second time', async () => {
|
||||
const res = await upload(Buffer.from(MAP_KML), 'alps.kml', 'import/gpx', {
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'canoe',
|
||||
water_context: 'river',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tours.map((tour: { name: string }) => tour.name)).toEqual(['Coast walk', 'Summit']);
|
||||
expect(res.body.tours[0]).toMatchObject({ tour_type: 'kayak', boat_type: 'canoe', origin: 'gpx' });
|
||||
expect(res.body.caution).toBe(true);
|
||||
expect(await countRows(orm, Tours)).toBe(2);
|
||||
const placeId = res.body.tours[1].place_id as number;
|
||||
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.body.provenance.source).toEqual({ kind: 'gpx', fileName: 'alps.kml' });
|
||||
|
||||
const again = await upload(Buffer.from(MAP_KML), 'alps.kml', 'import/gpx');
|
||||
expect(again.body).toMatchObject({ tours: [], skipped: 2 });
|
||||
expect(await countRows(orm, Tours)).toBe(2);
|
||||
});
|
||||
|
||||
it('reads a KMZ archive through its KML', async () => {
|
||||
const archive = await kmz({ 'doc.kml': MAP_KML, 'files/photo.jpg': 'jpeg' });
|
||||
const res = await upload(archive, 'alps.kmz');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.tracks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('400 with a reason for a broken KML, a broken or empty archive and a file without a line', async () => {
|
||||
const PINS_ONLY = '<kml><Placemark><Point><coordinates>1,2</coordinates></Point></Placemark></kml>';
|
||||
const answers = [
|
||||
await upload(Buffer.from('<kml><Placemark></kml>'), 'broken.kml'),
|
||||
await upload(Buffer.from('not a zip'), 'broken.kmz', 'import/gpx'),
|
||||
await upload(await kmz({ 'photo.jpg': 'jpeg' }), 'photos.kmz'),
|
||||
await upload(Buffer.from(PINS_ONLY), 'pins.kml'),
|
||||
];
|
||||
expect(answers.map((res) => [res.status, res.body])).toEqual([
|
||||
[400, { error: 'Malformed KML: invalid XML structure', code: 'malformed' }],
|
||||
[400, { error: 'Invalid KMZ archive.', code: 'archive_invalid' }],
|
||||
[400, { error: 'KMZ archive does not contain a KML file.', code: 'archive_without_kml' }],
|
||||
[400, { error: 'No line found in KML file', code: 'no_track' }],
|
||||
]);
|
||||
expect(await countRows(orm, Places)).toBe(0);
|
||||
});
|
||||
|
||||
it('a member without place_edit gets 403 for a KML file too', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/tours/import/gpx')
|
||||
.set('Cookie', sessionCookie(VIEWER))
|
||||
.attach('file', Buffer.from(MAP_KML), { filename: 'alps.kml', contentType: 'application/octet-stream' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(await countRows(orm, Places)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provenance (P2C-02)', () => {
|
||||
const detail = async (placeId: number) =>
|
||||
(await request(server).get(`/api/trips/5/tours/${placeId}`).set('Cookie', sessionCookie(OWNER))).body;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* IO-02: a KML or KMZ file read for the lines a Tours import stores. Each line
|
||||
* becomes one row in the shape the GPX import hands its persistence, named
|
||||
* after its placemark or, without a name, after the file as a GPX track is;
|
||||
* a KMZ is unpacked first, and what the file holds besides is counted.
|
||||
*/
|
||||
import { createGeometryNamer } from '../../../../src/nest/place-import/geometry-names';
|
||||
import { KmlReadError } from '../../../../src/nest/place-import/place-import.types';
|
||||
import { KmlProvider } from '../../../../src/nest/place-import/providers/kml.provider';
|
||||
import { buildPlaceImportService } from '../../../helpers/place-import';
|
||||
|
||||
import archiver from 'archiver';
|
||||
import { PassThrough } from 'stream';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const KML = `<?xml version="1.0"?><kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||
<Placemark><name>Coast</name><description>Windy</description>
|
||||
<LineString><altitudeMode>absolute</altitudeMode><coordinates>11,48,5 11.01,48.01,9</coordinates></LineString>
|
||||
</Placemark>
|
||||
<Placemark><LineString><coordinates>12,49 12.01,49.01</coordinates></LineString></Placemark>
|
||||
<Placemark><LineString><coordinates>13,50 13.01,50.01</coordinates></LineString></Placemark>
|
||||
<Placemark><name>Hut</name><Point><coordinates>12,49</coordinates></Point></Placemark>
|
||||
</Document></kml>`;
|
||||
|
||||
/** A KMZ archive holding these entries. */
|
||||
async function kmz(entries: Record<string, string>): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
const output = new PassThrough();
|
||||
output.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
const archive = archiver('zip', { zlib: { level: 1 } });
|
||||
archive.pipe(output);
|
||||
for (const [name, content] of Object.entries(entries)) archive.append(Buffer.from(content), { name });
|
||||
await archive.finalize();
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
describe('KmlProvider.readTracks', () => {
|
||||
const provider = new KmlProvider();
|
||||
|
||||
it('KML-TRACKS-001: makes a row of each line, starting where it starts, and counts the pin left out', async () => {
|
||||
const read = await provider.readTracks(Buffer.from(KML), 'alps.kml');
|
||||
expect(read.tracks).toEqual([
|
||||
{
|
||||
name: 'Coast',
|
||||
lat: 48,
|
||||
lng: 11,
|
||||
description: 'Windy',
|
||||
routeGeometry: JSON.stringify([
|
||||
[48, 11, 5],
|
||||
[48.01, 11.01, 9],
|
||||
]),
|
||||
},
|
||||
{ name: 'alps', lat: 49, lng: 12, description: null, routeGeometry: '[[49,12],[49.01,12.01]]' },
|
||||
{ name: 'alps 2', lat: 50, lng: 13, description: null, routeGeometry: '[[50,13],[50.01,13.01]]' },
|
||||
]);
|
||||
expect(read.leftOut).toEqual({ points: 1, areas: 0, other: 0 });
|
||||
});
|
||||
|
||||
it('KML-TRACKS-002: unpacks a KMZ by its extension and reads its doc.kml', async () => {
|
||||
const archive = await kmz({ 'files/notes.kml': '<kml/>', 'doc.kml': KML, 'files/photo.jpg': 'jpeg' });
|
||||
const read = await provider.readTracks(archive, 'Alps.KMZ');
|
||||
expect(read.tracks.map((track) => track.name)).toEqual(['Coast', 'Alps', 'Alps 2']);
|
||||
});
|
||||
|
||||
it('KML-TRACKS-003: an archive without a KML file is refused with its reason', async () => {
|
||||
const archive = await kmz({ 'photo.jpg': 'jpeg' });
|
||||
await expect(provider.readTracks(archive, 'trip.kmz')).rejects.toBeInstanceOf(KmlReadError);
|
||||
await expect(provider.readTracks(archive, 'trip.kmz')).rejects.toMatchObject({ reason: 'archive_without_kml' });
|
||||
});
|
||||
|
||||
it('KML-TRACKS-004: the facade hands the call to the provider', async () => {
|
||||
const read = await buildPlaceImportService().readMapFileTracks(Buffer.from(KML), 'alps.kml');
|
||||
expect(read.tracks).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGeometryNamer', () => {
|
||||
it('GEO-NAME-001: keeps a line its own name and numbers the unnamed ones after the file', () => {
|
||||
const name = createGeometryNamer('walks/harbour.gpx');
|
||||
expect([name('Pier', 'Track'), name(null, 'Track'), name(null, 'Track')]).toEqual([
|
||||
'Pier',
|
||||
'walks/harbour',
|
||||
'walks/harbour 2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('GEO-NAME-002: without a file name falls back to the label it is given', () => {
|
||||
const name = createGeometryNamer(' ');
|
||||
expect([name(null, 'KML Track'), name(null, 'KML Track')]).toEqual(['KML Track', 'KML Track 2']);
|
||||
expect(createGeometryNamer('.kml')(null, 'Track')).toBe('.kml');
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,24 @@
|
||||
import {
|
||||
decodeUtf8WithWarning,
|
||||
extractKmlPlacemarkNodes,
|
||||
kmlLineGeometry,
|
||||
KMZ_DECOMPRESSED_SIZE_LIMIT,
|
||||
parseKmlPointCoordinates,
|
||||
parsePlacemarkNode,
|
||||
readKmlDocument,
|
||||
readKmlLines,
|
||||
readKmzEntryCapped,
|
||||
sanitizeKmlDescription,
|
||||
unpackKmzToKml,
|
||||
} from '../../../../src/nest/place-import/kml.codec';
|
||||
import { KmlReadError } from '../../../../src/nest/place-import/place-import.types';
|
||||
import { buildCategoryNameLookup, resolveCategoryIdForFolder } from '../../../../src/nest/places/places.helpers';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PassThrough, Readable } from 'stream';
|
||||
import unzipper, { type Entry } from 'unzipper';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('kmlImportUtils', () => {
|
||||
it('sanitizes HTML descriptions with br to newline', () => {
|
||||
@@ -183,3 +189,199 @@ describe('readKmlDocument', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const kml = (body: string) =>
|
||||
Buffer.from(`<?xml version="1.0"?><kml xmlns="http://www.opengis.net/kml/2.2"
|
||||
xmlns:gx="http://www.google.com/kml/ext/2.2"><Document>${body}</Document></kml>`);
|
||||
|
||||
/** An archive entry whose stream yields these chunks, or fails after them. */
|
||||
const fakeEntry = (chunks: Buffer[], fail?: Error) => ({
|
||||
stream: () => {
|
||||
const out = new PassThrough();
|
||||
for (const chunk of chunks) out.write(chunk);
|
||||
if (fail) setImmediate(() => out.destroy(fail));
|
||||
else out.end();
|
||||
return out as unknown as Entry;
|
||||
},
|
||||
});
|
||||
|
||||
describe('readKmlLines (IO-02)', () => {
|
||||
it('KML-LINES-001: reads every LineString and gx:Track in document order, each a line of its own', () => {
|
||||
const read = readKmlLines(
|
||||
kml(`<Placemark><name>Coast</name><description>Along the & sea</description>
|
||||
<LineString><coordinates>11,48 11.01,48.01</coordinates></LineString></Placemark>
|
||||
<Folder><name>Recorded</name><Placemark><name>Morning</name><gx:Track><altitudeMode>absolute</altitudeMode>
|
||||
<when>2026-01-01T08:00:00Z</when><when>2026-01-01T08:05:00Z</when>
|
||||
<gx:coord>12 49 400</gx:coord><gx:coord>12.01 49.01 420.5</gx:coord></gx:Track></Placemark></Folder>`),
|
||||
);
|
||||
expect(read.lines).toEqual([
|
||||
{
|
||||
name: 'Coast',
|
||||
description: 'Along the & sea',
|
||||
geometry: [
|
||||
[48, 11],
|
||||
[48.01, 11.01],
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Morning',
|
||||
description: null,
|
||||
geometry: [
|
||||
[49, 12, 400],
|
||||
[49.01, 12.01, 420.5],
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(read.leftOut).toEqual({ points: 0, areas: 0, other: 0 });
|
||||
expect(read.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('KML-LINES-002: keeps the lines of one placemark apart and numbers them, never joins them', () => {
|
||||
const read = readKmlLines(
|
||||
kml(`<Placemark><name>Ferry day</name><MultiGeometry>
|
||||
<LineString><coordinates>11,48 11.01,48.01</coordinates></LineString>
|
||||
<MultiGeometry><LineString><coordinates>11.5,48.5 11.51,48.51</coordinates></LineString>
|
||||
<Point><coordinates>11.5,48.5</coordinates></Point></MultiGeometry>
|
||||
</MultiGeometry></Placemark>
|
||||
<Placemark><gx:MultiTrack><altitudeMode>absolute</altitudeMode>
|
||||
<gx:Track><gx:coord>12 49 1</gx:coord><gx:coord>12.1 49.1 2</gx:coord></gx:Track>
|
||||
<gx:Track><gx:coord>13 50 3</gx:coord><gx:coord>13.1 50.1 4</gx:coord></gx:Track></gx:MultiTrack></Placemark>`),
|
||||
);
|
||||
expect(read.lines.map((line) => [line.name, line.geometry.length, line.geometry[0]])).toEqual([
|
||||
['Ferry day (1/2)', 2, [48, 11]],
|
||||
['Ferry day (2/2)', 2, [48.5, 11.5]],
|
||||
[null, 2, [49, 12, 1]],
|
||||
[null, 2, [50, 13, 3]],
|
||||
]);
|
||||
expect(read.leftOut).toEqual({ points: 1, areas: 0, other: 0 });
|
||||
});
|
||||
|
||||
it('KML-LINES-003: counts what it leaves out: pins, areas, models, overlays, links, empty and short ones', () => {
|
||||
const read = readKmlLines(
|
||||
kml(`<Placemark><name>Hut</name><Point><coordinates>11,48</coordinates></Point></Placemark>
|
||||
<Placemark><Polygon><outerBoundaryIs><LinearRing><coordinates>1,1 2,2 3,1 1,1</coordinates></LinearRing>
|
||||
</outerBoundaryIs></Polygon></Placemark>
|
||||
<Placemark><Model><Link><href>house.dae</href></Link></Model></Placemark>
|
||||
<Placemark><name>Nothing</name></Placemark>
|
||||
<Placemark><LineString><coordinates>11,48</coordinates></LineString></Placemark>
|
||||
<Placemark><gx:Track><gx:coord>12 49 1</gx:coord></gx:Track></Placemark>
|
||||
<NetworkLink><Link><href>https://example.test/more.kml</href></Link></NetworkLink>
|
||||
<Folder><GroundOverlay><Icon><href>map.png</href></Icon></GroundOverlay><ScreenOverlay/></Folder>`),
|
||||
);
|
||||
expect(read.lines).toEqual([]);
|
||||
// other: the model, the empty placemark, the short line, the short track, the link and two overlays.
|
||||
expect(read.leftOut).toEqual({ points: 1, areas: 1, other: 7 });
|
||||
});
|
||||
|
||||
it('KML-LINES-004: says when the file is not valid UTF-8, as the place import does', () => {
|
||||
const bytes = Buffer.concat([
|
||||
Buffer.from('<kml><Placemark><name>'),
|
||||
Buffer.from([0xff]),
|
||||
Buffer.from('</name><LineString><coordinates>1,1 2,2</coordinates></LineString></Placemark></kml>'),
|
||||
]);
|
||||
const read = readKmlLines(bytes);
|
||||
expect(read.lines).toHaveLength(1);
|
||||
expect(read.warnings).toEqual(['The uploaded file is not valid UTF-8. Some characters may be shown incorrectly.']);
|
||||
});
|
||||
|
||||
it('KML-LINES-005: refuses a document that is not well-formed XML with the reason malformed', () => {
|
||||
let error: unknown;
|
||||
try {
|
||||
readKmlLines(Buffer.from('<kml><Placemark></kml>'));
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
expect(error).toBeInstanceOf(KmlReadError);
|
||||
expect(error).toMatchObject({ reason: 'malformed', message: 'Malformed KML: invalid XML structure' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('kmlLineGeometry (IO-02): heights only where they are heights', () => {
|
||||
const points = (...eles: Array<number | null>) => eles.map((ele, i) => ({ lat: 47 + i, lng: 11, ele }));
|
||||
|
||||
it('KML-HEIGHTS-001: keeps the heights of an absolute line, also when they are 0', () => {
|
||||
expect(kmlLineGeometry(points(0, 0), 'absolute')).toEqual([
|
||||
[47, 11, 0],
|
||||
[48, 11, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('KML-HEIGHTS-002: drops them under any other altitude mode', () => {
|
||||
for (const mode of ['clampToGround', 'relativeToGround', 'clampToSeaFloor']) {
|
||||
expect(kmlLineGeometry(points(400, 420), mode)).toEqual([
|
||||
[47, 11],
|
||||
[48, 11],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('KML-HEIGHTS-003: without a mode keeps them unless every one is 0, the placeholder Google My Maps writes', () => {
|
||||
expect(kmlLineGeometry(points(0, 0, 0), null)).toEqual([
|
||||
[47, 11],
|
||||
[48, 11],
|
||||
[49, 11],
|
||||
]);
|
||||
expect(kmlLineGeometry(points(0, 12), null)).toEqual([
|
||||
[47, 11, 0],
|
||||
[48, 11, 12],
|
||||
]);
|
||||
});
|
||||
|
||||
it('KML-HEIGHTS-004: a line with a point lacking its height keeps none', () => {
|
||||
expect(kmlLineGeometry(points(400, null), 'absolute')).toEqual([
|
||||
[47, 11],
|
||||
[48, 11],
|
||||
]);
|
||||
});
|
||||
|
||||
it('KML-HEIGHTS-005: an altitude mode around the lines is inherited by them', () => {
|
||||
const read = readKmlLines(
|
||||
kml(`<Placemark><MultiGeometry><altitudeMode>clampToGround</altitudeMode>
|
||||
<LineString><coordinates>11,48,300 11.01,48.01,310</coordinates></LineString></MultiGeometry></Placemark>`),
|
||||
);
|
||||
expect(read.lines[0]?.geometry).toEqual([
|
||||
[48, 11],
|
||||
[48.01, 11.01],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KMZ unpacking limits (IO-02)', () => {
|
||||
it('KMZ-CAP-001: counts the bytes it inflates and stops past the limit, whatever the header claims', async () => {
|
||||
const entry = fakeEntry([Buffer.alloc(6, 'a'), Buffer.alloc(6, 'b')]);
|
||||
await expect(readKmzEntryCapped(entry, 10)).rejects.toMatchObject({ reason: 'archive_too_large' });
|
||||
expect((await readKmzEntryCapped(entry, 12)).toString()).toBe('aaaaaabbbbbb');
|
||||
});
|
||||
|
||||
it('KMZ-CAP-002: names the reason for each archive it cannot read', async () => {
|
||||
await expect(unpackKmzToKml(Buffer.from('this is not a zip'))).rejects.toMatchObject({ reason: 'archive_invalid' });
|
||||
await expect(unpackKmzToKml(fs.readFileSync(KMZ_FIXTURE), 1)).rejects.toMatchObject({
|
||||
reason: 'archive_too_large',
|
||||
});
|
||||
});
|
||||
|
||||
it('KMZ-CAP-003: a KML entry that breaks while it inflates is an invalid archive', async () => {
|
||||
const broken = { path: 'doc.kml', uncompressedSize: 10, ...fakeEntry([Buffer.from('<kml>')], new Error('bad')) };
|
||||
const open = vi.spyOn(unzipper.Open, 'buffer').mockResolvedValueOnce({ files: [broken] } as never);
|
||||
await expect(unpackKmzToKml(Buffer.from('zip'))).rejects.toMatchObject({
|
||||
reason: 'archive_invalid',
|
||||
message: 'Invalid KMZ archive.',
|
||||
});
|
||||
open.mockRestore();
|
||||
});
|
||||
|
||||
it('KMZ-CAP-004: reads only the KML entry of an archive, never the files beside it', async () => {
|
||||
const image = { path: 'files/photo.jpg', uncompressedSize: 1, stream: vi.fn() };
|
||||
const doc = { path: 'doc.kml', uncompressedSize: 5, ...fakeEntry([Buffer.from('<kml/>')]) };
|
||||
const open = vi.spyOn(unzipper.Open, 'buffer').mockResolvedValueOnce({ files: [image, doc] } as never);
|
||||
expect((await unpackKmzToKml(Buffer.from('zip'))).toString()).toBe('<kml/>');
|
||||
expect(image.stream).not.toHaveBeenCalled();
|
||||
open.mockRestore();
|
||||
});
|
||||
|
||||
it('KMZ-CAP-005: takes chunks that come as plain byte arrays', async () => {
|
||||
const chunks = [new Uint8Array([60, 107]), Buffer.from('ml/>')];
|
||||
const entry = { stream: () => Readable.from(chunks) as unknown as Entry };
|
||||
expect((await readKmzEntryCapped(entry, 10)).toString()).toBe('<kml/>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,7 +92,7 @@ function importFixture(
|
||||
canEdit: vi.fn(async () => o.canEdit ?? true),
|
||||
};
|
||||
const result = 'result' in o ? o.result : { tours: [{ place_id: 42 }], caution: false, skipped: 0 };
|
||||
const tours = { importGpxAsTour: vi.fn(async () => result) };
|
||||
const tours = { importGpxAsTour: vi.fn(async () => result), previewGpx: vi.fn(async () => result) };
|
||||
const controller = new ToursImportController(
|
||||
tours as unknown as ToursService,
|
||||
places as unknown as PlacesService,
|
||||
@@ -278,4 +278,28 @@ describe('ToursImportController (POST /api/trips/:tripId/tours/import/gpx)', ()
|
||||
body: { error: 'No track or route found in GPX file' },
|
||||
});
|
||||
});
|
||||
|
||||
it('TOURS-IMP-009: a KML or KMZ file without a line names its format and carries the code (IO-02)', async () => {
|
||||
const { controller, tours } = importFixture({ result: null });
|
||||
const kml = { buffer: Buffer.from('<kml/>'), originalname: 'pins.kml' } as Express.Multer.File;
|
||||
const kmz = { buffer: Buffer.from('zip'), originalname: 'pins.kmz' } as Express.Multer.File;
|
||||
|
||||
expect(await thrownAsync(() => controller.importGpx(user, '7', kml))).toEqual({
|
||||
status: 400,
|
||||
body: { error: 'No line found in KML file', code: 'no_track' },
|
||||
});
|
||||
expect(await thrownAsync(() => controller.previewGpx(user, '7', kmz))).toEqual({
|
||||
status: 400,
|
||||
body: { error: 'No line found in KMZ file', code: 'no_track' },
|
||||
});
|
||||
expect(tours.previewGpx).toHaveBeenCalledWith('7', kmz.buffer, 'pins.kmz');
|
||||
});
|
||||
|
||||
it('TOURS-IMP-010: the preview hands the file on and returns what the service read', async () => {
|
||||
const preview = { tracks: [], left_out: { points: 2, areas: 0, other: 1 } };
|
||||
const { controller, tours } = importFixture({ result: preview });
|
||||
|
||||
expect(await controller.previewGpx(user, '7', file)).toBe(preview);
|
||||
expect(tours.previewGpx).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* IO-02: the file a Tours import reads. A GPX file goes through the GPX reader
|
||||
* without its waypoints, as it always did; a KML or KMZ file, by its
|
||||
* extension, through the KML reader for its lines and what it leaves out. A
|
||||
* file that cannot be read is a 400 that names its reason, and a file without
|
||||
* a line answers GPX's body as before and a format-named one with a code.
|
||||
*/
|
||||
import type { PlaceImportService } from '../../../src/nest/place-import/place-import.service';
|
||||
import { KmlReadError } from '../../../src/nest/place-import/place-import.types';
|
||||
import { noTrackFound, readTourFile } from '../../../src/nest/tours/tour-file-import.helpers';
|
||||
import { buildPlaceImportService } from '../../helpers/place-import';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const GPX = Buffer.from(`<gpx><wpt lat="1" lon="2"><name>Hut</name></wpt>
|
||||
<trk><trkseg><trkpt lat="48" lon="11"/><trkpt lat="48.01" lon="11.01"/></trkseg></trk></gpx>`);
|
||||
const KML = Buffer.from(`<kml><Placemark><name>Coast</name>
|
||||
<LineString><coordinates>11,48 11.01,48.01</coordinates></LineString></Placemark>
|
||||
<Placemark><Polygon/></Placemark></kml>`);
|
||||
|
||||
describe('readTourFile', () => {
|
||||
it('TOUR-FILE-001: reads a GPX file for its tracks, never its waypoints, and counts nothing left out', async () => {
|
||||
const read = await readTourFile(buildPlaceImportService(), GPX, 'ridge.gpx');
|
||||
expect(read.rows.map((row) => row.name)).toEqual(['ridge']);
|
||||
expect(read.leftOut).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TOUR-FILE-002: reads a file without a known extension, or without a name, as GPX as before', async () => {
|
||||
expect((await readTourFile(buildPlaceImportService(), GPX, 'track.xml')).rows).toHaveLength(1);
|
||||
expect((await readTourFile(buildPlaceImportService(), GPX, undefined)).rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('TOUR-FILE-003: reads a KML file for its lines and says what it held besides', async () => {
|
||||
const read = await readTourFile(buildPlaceImportService(), KML, 'coast.KML');
|
||||
expect(read.rows.map((row) => row.name)).toEqual(['Coast']);
|
||||
expect(read.leftOut).toEqual({ points: 0, areas: 1, other: 0 });
|
||||
});
|
||||
|
||||
it('TOUR-FILE-004: a file it cannot read is a 400 carrying the reason as its code', async () => {
|
||||
let error: unknown;
|
||||
try {
|
||||
await readTourFile(buildPlaceImportService(), Buffer.from('not a zip'), 'trip.kmz');
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
expect(error).toBeInstanceOf(HttpException);
|
||||
expect((error as HttpException).getStatus()).toBe(400);
|
||||
expect((error as HttpException).getResponse()).toEqual({ error: 'Invalid KMZ archive.', code: 'archive_invalid' });
|
||||
});
|
||||
|
||||
it('TOUR-FILE-005: any other failure passes through as it is', async () => {
|
||||
const placeImport = {
|
||||
readMapFileTracks: vi.fn().mockRejectedValue(new Error('disk gone')),
|
||||
} as unknown as PlaceImportService;
|
||||
await expect(readTourFile(placeImport, KML, 'coast.kml')).rejects.toThrow('disk gone');
|
||||
const refused = { readMapFileTracks: vi.fn().mockRejectedValue(new KmlReadError('archive_too_large', 'Too big')) };
|
||||
await expect(readTourFile(refused as unknown as PlaceImportService, KML, 'x.kmz')).rejects.toMatchObject({
|
||||
code: 'tour_file_unreadable',
|
||||
message: 'Too big',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('noTrackFound', () => {
|
||||
it('TOUR-FILE-006: answers a GPX file with the body the route has always sent', () => {
|
||||
const error = noTrackFound('ridge.gpx');
|
||||
expect(error.getStatus()).toBe(400);
|
||||
expect(error.getResponse()).toEqual({ error: 'No track or route found in GPX file' });
|
||||
expect(noTrackFound(undefined).getResponse()).toEqual({ error: 'No track or route found in GPX file' });
|
||||
});
|
||||
|
||||
it('TOUR-FILE-007: names the format of a KML or KMZ file and carries the code', () => {
|
||||
expect(noTrackFound('pins.kml').getResponse()).toEqual({ error: 'No line found in KML file', code: 'no_track' });
|
||||
expect(noTrackFound('pins.kmz').getResponse()).toEqual({ error: 'No line found in KMZ file', code: 'no_track' });
|
||||
});
|
||||
});
|
||||
@@ -441,3 +441,43 @@ describe('Tours GPX atomic persistence and postcommit publication', () => {
|
||||
expect(broadcast.mock.calls.map((call) => call[1])).toEqual(['tours:changed', 'place:created']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tours KML import through the same persistence (IO-02)', () => {
|
||||
const twoLines = Buffer.from(`<kml><Document>
|
||||
<Placemark><name>Coast</name><LineString><coordinates>11,48,0 11.01,48.01,0</coordinates></LineString></Placemark>
|
||||
<Placemark><name>Ridge</name><LineString><altitudeMode>absolute</altitudeMode>
|
||||
<coordinates>12,49,100 12.01,49.01,150</coordinates></LineString></Placemark>
|
||||
<Placemark><name>Hut</name><Point><coordinates>12,49</coordinates></Point></Placemark>
|
||||
</Document></kml>`);
|
||||
|
||||
it('stores each line as a tour of its own, heights only where the file has them', async () => {
|
||||
const result = (await tours.importGpxAsTour(tripId, twoLines, 'alps.kml', 'socket'))!;
|
||||
|
||||
expect(result.tours.map((tour) => [tour.name, tour.origin, tour.elevation_gain])).toEqual([
|
||||
['Coast', 'gpx', null],
|
||||
['Ridge', 'gpx', 50],
|
||||
]);
|
||||
expect(result.caution).toBe(true);
|
||||
expect(await count('places')).toBe(2);
|
||||
expect(await count('tours')).toBe(2);
|
||||
});
|
||||
|
||||
it('rolls every line back when a later one fails, and stores nothing', async () => {
|
||||
testDb.exec(`CREATE TRIGGER fail_place BEFORE INSERT ON places WHEN NEW.name = 'Ridge'
|
||||
BEGIN SELECT RAISE(ABORT, 'place failure'); END;`);
|
||||
await expect(tours.importGpxAsTour(tripId, twoLines, 'alps.kml')).rejects.toThrow('place failure');
|
||||
for (const table of ['places', 'tours', 'tour_waypoints'] as const) expect(await count(table)).toBe(0);
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a broken file before any transaction', async () => {
|
||||
const transactional = vi.spyOn(uow, 'transactional');
|
||||
const broken = Buffer.from('<kml><Placemark></kml>');
|
||||
await expect(tours.importGpxAsTour(tripId, broken, 'broken.kml')).rejects.toMatchObject({
|
||||
code: 'tour_file_unreadable',
|
||||
message: 'Malformed KML: invalid XML structure',
|
||||
});
|
||||
expect(transactional).not.toHaveBeenCalled();
|
||||
expect(await count('places')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+39
-14
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'عرض الإضافة',
|
||||
'tours.title': 'الجولات',
|
||||
'tours.empty.title': 'لا توجد جولات بعد',
|
||||
'tours.empty.body': 'خطّط لجولة أو استورد ملف GPX للبدء.',
|
||||
'tours.empty.body': 'خطّط لجولة أو استورد ملف GPX أو KML للبدء.',
|
||||
'tours.addToDay': 'إضافة إلى يوم',
|
||||
'tours.addToDay.comingSoon': 'عيّن الجولات لأحد أيام الرحلة من قائمة الجولات.',
|
||||
'tours.addToDay.pickDay': 'اختيار يوم',
|
||||
@@ -20,17 +20,17 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'يُرجى الاستخدام بحذر',
|
||||
'tours.caution.tooltip': 'بيانات الارتفاع أو المقاييس غير مكتملة؛ تم الاستيراد مع تنبيه للحذر',
|
||||
'tours.import.caution': 'تم الاستيراد مع تنبيه للحذر؛ بيانات الارتفاع أو المقاييس غير مكتملة',
|
||||
'tours.import.success': 'تم استيراد {count} جولة من GPX',
|
||||
'tours.import.success.zero': 'لم يتم استيراد أي جولة من GPX',
|
||||
'tours.import.success.one': 'تم استيراد جولة واحدة من GPX',
|
||||
'tours.import.success.two': 'تم استيراد جولتين من GPX',
|
||||
'tours.import.success.few': 'تم استيراد {count} جولات من GPX',
|
||||
'tours.import.success.many': 'تم استيراد {count} جولة من GPX',
|
||||
'tours.import.success': 'تم استيراد {count} جولة',
|
||||
'tours.import.success.zero': 'لم يتم استيراد أي جولة',
|
||||
'tours.import.success.one': 'تم استيراد جولة واحدة',
|
||||
'tours.import.success.two': 'تم استيراد جولتين',
|
||||
'tours.import.success.few': 'تم استيراد {count} جولات',
|
||||
'tours.import.success.many': 'تم استيراد {count} جولة',
|
||||
'tours.import.noTrackIn': 'لم يُعثر على أثر مسجّل أو مسار في {name}',
|
||||
'tours.import.errorIn': 'تعذّر استيراد {name}',
|
||||
'tours.import.button': 'استيراد GPX',
|
||||
'tours.import.hint': 'استيراد ملفات GPX كجولات في هذه الرحلة',
|
||||
'tours.import.unsupported': 'يمكن استيراد ملفات GPX فقط كجولات.',
|
||||
'tours.import.button': 'استيراد GPX أو KML',
|
||||
'tours.import.hint': 'استيراد ملفات GPX أو KML أو KMZ كجولات في هذه الرحلة',
|
||||
'tours.import.unsupported': 'يمكن استيراد ملفات GPX وKML وKMZ فقط كجولات.',
|
||||
'tours.import.skipped': 'تم تخطي {count} أثر مسجّل لأنها موجودة بالفعل في هذه الرحلة',
|
||||
'tours.import.skipped.zero': 'لم يتم تخطي أي أثر مسجّل',
|
||||
'tours.import.skipped.one': 'تم تخطي أثر مسجّل واحد لأنه موجود بالفعل في هذه الرحلة',
|
||||
@@ -38,10 +38,10 @@ const tours: TranslationStrings = {
|
||||
'tours.import.skipped.few': 'تم تخطي {count} آثار مسجّلة لأنها موجودة بالفعل في هذه الرحلة',
|
||||
'tours.import.skipped.many': 'تم تخطي {count} أثرًا مسجّلًا لأنها موجودة بالفعل في هذه الرحلة',
|
||||
'tours.import.allSkipped': 'كل ما في هذا الملف موجود بالفعل في هذه الرحلة.',
|
||||
'tours.import.drop': 'أفلت ملفات GPX لاستيرادها',
|
||||
'tours.import.drop': 'أفلت ملفات GPX أو KML أو KMZ لاستيرادها',
|
||||
'tours.import.dropHint': 'يصبح كل أثر مسجّل أو مسار في الملف جولة في هذه الرحلة.',
|
||||
'tours.import.dropNoPermission': 'لا يمكنك إضافة جولات إلى هذه الرحلة.',
|
||||
'tours.import.importing': 'جارٍ استيراد GPX…',
|
||||
'tours.import.importing': 'جارٍ الاستيراد…',
|
||||
'tours.days.label': 'أيام الرحلة',
|
||||
'tours.days.none': 'ليست مضافة إلى أي يوم من أيام الرحلة بعد.',
|
||||
'tours.days.short': 'الأيام {days}',
|
||||
@@ -109,7 +109,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'تخطيط جولة جديدة',
|
||||
'tours.planner.addTour': 'إضافة جولة',
|
||||
'tours.planner.addTourHint':
|
||||
'خطط لجولة جديدة أو استورد ملفات GPX أو جولة مشي من Google Maps أو انقل جولة من رحلة أخرى',
|
||||
'خطط لجولة جديدة أو استورد ملفات GPX أو KML أو جولة مشي من Google Maps أو انقل جولة من رحلة أخرى',
|
||||
'tours.planner.neutralTitle': 'تخطيط جولة',
|
||||
'tours.planner.neutralBody': 'ابدأ جولة جديدة وانقر على الخريطة لتحديد نقاط المسار.',
|
||||
'tours.planner.newTitle': 'جولة جديدة',
|
||||
@@ -247,6 +247,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'المصدر الأصلي',
|
||||
'tours.provenance.source': 'المصدر',
|
||||
'tours.provenance.importedAt': 'تم الاستيراد',
|
||||
'tours.provenance.fileImport': 'استيراد {format}',
|
||||
'tours.provenance.createdAt': 'تم الإنشاء',
|
||||
'tours.provenance.externalUpdated': 'تغيّر في wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -782,7 +783,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'بيانات خريطة وليست تقريرًا حيًا: لا تقول شيئًا عما هو مفتوح أو مُجهّز، ولا عن الثلج أو الانهيارات الثلجية.',
|
||||
'tours.map.pisteFullKey': 'المفتاح الكامل على OpenSnowMap',
|
||||
'tours.import.previewTitle': 'معاينة GPX',
|
||||
'tours.import.previewTitle': 'معاينة الاستيراد',
|
||||
'tours.import.previewHint': 'لم يُحفظ شيء بعد. تحقق من المسار ثم استورد.',
|
||||
'tours.import.previewFiles': '{count} ملف',
|
||||
'tours.import.previewFiles.zero': '{count} ملفات',
|
||||
@@ -803,6 +804,30 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints.few': '{count} نقاط',
|
||||
'tours.import.previewPoints.many': '{count} نقطة',
|
||||
'tours.import.previewNoHeights': 'لا توجد ارتفاعات في الملف',
|
||||
'tours.import.previewLeftOut': 'لم يُستورد',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'تتكوّن الجولات من الخطوط فقط. تبقى الدبابيس والمساحات والطبقات والروابط إلى ملفات أخرى خارج الاستيراد، ولا يُحمَّل أي شيء من مكان آخر.',
|
||||
'tours.import.leftOutPoints': '{count} دبوس',
|
||||
'tours.import.leftOutPoints.zero': '{count} دبابيس',
|
||||
'tours.import.leftOutPoints.one': 'دبوس واحد',
|
||||
'tours.import.leftOutPoints.two': 'دبوسان',
|
||||
'tours.import.leftOutPoints.few': '{count} دبابيس',
|
||||
'tours.import.leftOutPoints.many': '{count} دبوسًا',
|
||||
'tours.import.leftOutAreas': '{count} مساحة',
|
||||
'tours.import.leftOutAreas.zero': '{count} مساحات',
|
||||
'tours.import.leftOutAreas.one': 'مساحة واحدة',
|
||||
'tours.import.leftOutAreas.two': 'مساحتان',
|
||||
'tours.import.leftOutAreas.few': '{count} مساحات',
|
||||
'tours.import.leftOutAreas.many': '{count} مساحة',
|
||||
'tours.import.leftOutOther': '{count} عنصر آخر',
|
||||
'tours.import.leftOutOther.zero': '{count} عناصر أخرى',
|
||||
'tours.import.leftOutOther.one': 'عنصر آخر واحد',
|
||||
'tours.import.leftOutOther.two': 'عنصران آخران',
|
||||
'tours.import.leftOutOther.few': '{count} عناصر أخرى',
|
||||
'tours.import.leftOutOther.many': '{count} عنصرًا آخر',
|
||||
'tours.import.unreadableIn': 'تعذّرت قراءة {name}: الملف تالف أو ليس GPX أو KML أو KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'لم يُعثر على ملف KML في {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} كبير جدًا بعد فك الضغط',
|
||||
'tours.import.previewDuplicate': 'موجود بالفعل في الرحلة',
|
||||
'tours.import.previewAllDuplicates': 'كل المسارات موجودة بالفعل في الرحلة، فلا شيء للاستيراد.',
|
||||
'tours.import.previewAs': 'الاستيراد كـ',
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Əlavə etmə görünüşü',
|
||||
'tours.title': 'Turlar',
|
||||
'tours.empty.title': 'Hələ tur yoxdur',
|
||||
'tours.empty.body': 'Başlamaq üçün tur planlaşdırın və ya GPX faylı idxal edin.',
|
||||
'tours.empty.body': 'Başlamaq üçün tur planlaşdırın və ya GPX ya KML faylı idxal edin.',
|
||||
'tours.addToDay': 'Günə əlavə et',
|
||||
'tours.addToDay.comingSoon': 'Tur siyahısından turları səyahət gününə təyin edin.',
|
||||
'tours.addToDay.pickDay': 'Gün seç',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'ehtiyatla istifadə edin',
|
||||
'tours.caution.tooltip': 'Yüksəklik və göstəricilər natamamdır — ehtiyatla idxal edilib',
|
||||
'tours.import.caution': 'Ehtiyatla idxal edilib — yüksəklik və göstəricilər natamamdır',
|
||||
'tours.import.success': 'GPX-dən {count} tur idxal edildi',
|
||||
'tours.import.success.one': 'GPX-dən {count} tur idxal edildi',
|
||||
'tours.import.success': '{count} tur idxal edildi',
|
||||
'tours.import.success.one': '{count} tur idxal edildi',
|
||||
'tours.import.noTrackIn': '{name} daxilində iz və ya marşrut tapılmadı',
|
||||
'tours.import.errorIn': '{name} faylını idxal etmək mümkün olmadı',
|
||||
'tours.import.button': 'GPX idxal et',
|
||||
'tours.import.hint': 'GPX fayllarını bu səyahətin turları kimi idxal edin',
|
||||
'tours.import.unsupported': 'Tur kimi yalnız GPX faylları idxal edilə bilər.',
|
||||
'tours.import.button': 'GPX və ya KML idxal et',
|
||||
'tours.import.hint': 'GPX, KML və ya KMZ fayllarını bu səyahətin turları kimi idxal edin',
|
||||
'tours.import.unsupported': 'Tur kimi yalnız GPX, KML və KMZ faylları idxal edilə bilər.',
|
||||
'tours.import.skipped': '{count} iz artıq bu səyahətdə idi və ötürüldü',
|
||||
'tours.import.skipped.one': '{count} iz artıq bu səyahətdə idi və ötürüldü',
|
||||
'tours.import.allSkipped': 'Həmin fayldakı hər şey artıq bu səyahətdədir.',
|
||||
'tours.import.drop': 'İdxal etmək üçün GPX fayllarını buraya atın',
|
||||
'tours.import.drop': 'İdxal etmək üçün GPX, KML və ya KMZ fayllarını buraya atın',
|
||||
'tours.import.dropHint': 'Fayldakı hər iz və ya marşrut bu səyahətin turuna çevrilir.',
|
||||
'tours.import.dropNoPermission': 'Bu səyahətə tur əlavə edə bilməzsiniz.',
|
||||
'tours.import.importing': 'GPX idxal edilir…',
|
||||
'tours.import.importing': 'İdxal edilir…',
|
||||
'tours.days.label': 'Səyahət günləri',
|
||||
'tours.days.none': 'Hələ səyahətin heç bir gününə əlavə edilməyib.',
|
||||
'tours.days.short': 'Günlər {days}',
|
||||
@@ -95,7 +95,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Yeni tur planlaşdır',
|
||||
'tours.planner.addTour': 'Tur əlavə et',
|
||||
'tours.planner.addTourHint':
|
||||
'Yeni tur planla, GPX fayllarını və ya Google Maps-dən gəzinti idxal et, ya da başqa səfərdən tur götür',
|
||||
'Yeni tur planla, GPX və ya KML fayllarını, ya da Google Maps-dən gəzinti idxal et, ya da başqa səfərdən tur götür',
|
||||
'tours.planner.neutralTitle': 'Tur planlaşdır',
|
||||
'tours.planner.neutralBody': 'Yeni tur başladın və marşrut nöqtələrini qoymaq üçün xəritəyə klikləyin.',
|
||||
'tours.planner.newTitle': 'Yeni tur',
|
||||
@@ -233,6 +233,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Mənşə',
|
||||
'tours.provenance.source': 'Mənbə',
|
||||
'tours.provenance.importedAt': 'İdxal edilib',
|
||||
'tours.provenance.fileImport': '{format} idxalı',
|
||||
'tours.provenance.createdAt': 'Yaradılıb',
|
||||
'tours.provenance.externalUpdated': 'wanderer-də dəyişib',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -787,7 +788,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Xəritə məlumatıdır, canlı hesabat deyil: nəyin açıq və ya hazırlanmış olduğu, qar və uçqunlar barədə heç nə demir.',
|
||||
'tours.map.pisteFullKey': 'Tam legenda OpenSnowMap-də',
|
||||
'tours.import.previewTitle': 'GPX önbaxışı',
|
||||
'tours.import.previewTitle': 'İdxal önbaxışı',
|
||||
'tours.import.previewHint': 'Hələ heç nə yadda saxlanmayıb. Marşrutu yoxla, sonra idxal et.',
|
||||
'tours.import.previewFiles': '{count} fayl',
|
||||
'tours.import.previewFiles.one': '{count} fayl',
|
||||
@@ -796,6 +797,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} nöqtə',
|
||||
'tours.import.previewPoints.one': '{count} nöqtə',
|
||||
'tours.import.previewNoHeights': 'Faylda hündürlük yoxdur',
|
||||
'tours.import.previewLeftOut': 'İdxal edilmir',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Turlar yalnız xətlərdən yaranır. Sancaqlar, sahələr, örtüklər və başqa fayllara keçidlər kənarda qalır, heç nə başqa yerdən yüklənmir.',
|
||||
'tours.import.leftOutPoints': '{count} sancaq',
|
||||
'tours.import.leftOutPoints.one': '{count} sancaq',
|
||||
'tours.import.leftOutAreas': '{count} sahə',
|
||||
'tours.import.leftOutAreas.one': '{count} sahə',
|
||||
'tours.import.leftOutOther': '{count} digər element',
|
||||
'tours.import.leftOutOther.one': '{count} digər element',
|
||||
'tours.import.unreadableIn': '{name} oxunmadı: fayl zədələnib və ya GPX, KML, KMZ deyil',
|
||||
'tours.import.archiveNoKmlIn': '{name} içində KML faylı tapılmadı',
|
||||
'tours.import.archiveTooLargeIn': '{name} açıldıqdan sonra çox böyükdür',
|
||||
'tours.import.previewDuplicate': 'Artıq səyahətdədir',
|
||||
'tours.import.previewAllDuplicates': 'Bütün treklər artıq səyahətdədir, idxal ediləcək heç nə yoxdur.',
|
||||
'tours.import.previewAs': 'Bu kimi idxal et',
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Adicionar visualização',
|
||||
'tours.title': 'Passeios',
|
||||
'tours.empty.title': 'Nenhum passeio ainda',
|
||||
'tours.empty.body': 'Planeje um passeio ou importe um arquivo GPX para começar.',
|
||||
'tours.empty.body': 'Planeje um passeio ou importe um arquivo GPX ou KML para começar.',
|
||||
'tours.addToDay': 'Adicionar a um dia',
|
||||
'tours.addToDay.comingSoon': 'Atribua passeios a um dia da viagem pela lista de passeios.',
|
||||
'tours.addToDay.pickDay': 'Escolha um dia',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'use com cautela',
|
||||
'tours.caution.tooltip': 'Elevação ou métricas incompletas: importado com ressalvas',
|
||||
'tours.import.caution': 'Importado com ressalvas: elevação ou métricas incompletas',
|
||||
'tours.import.success': '{count} passeios importados de GPX',
|
||||
'tours.import.success.one': '{count} passeio importado de GPX',
|
||||
'tours.import.success': '{count} passeios importados',
|
||||
'tours.import.success.one': '{count} passeio importado',
|
||||
'tours.import.noTrackIn': 'Nenhum traçado ou rota encontrado em {name}',
|
||||
'tours.import.errorIn': 'Não foi possível importar {name}',
|
||||
'tours.import.button': 'Importar GPX',
|
||||
'tours.import.hint': 'Importe arquivos GPX como passeios desta viagem',
|
||||
'tours.import.unsupported': 'Só arquivos GPX podem ser importados como passeios.',
|
||||
'tours.import.button': 'Importar GPX ou KML',
|
||||
'tours.import.hint': 'Importe arquivos GPX, KML ou KMZ como passeios desta viagem',
|
||||
'tours.import.unsupported': 'Só arquivos GPX, KML e KMZ podem ser importados como passeios.',
|
||||
'tours.import.skipped': '{count} traçados já estavam nesta viagem e foram ignorados',
|
||||
'tours.import.skipped.one': '{count} traçado já estava nesta viagem e foi ignorado',
|
||||
'tours.import.allSkipped': 'Tudo o que há nesse arquivo já está nesta viagem.',
|
||||
'tours.import.drop': 'Solte arquivos GPX para importá-los',
|
||||
'tours.import.drop': 'Solte arquivos GPX, KML ou KMZ para importá-los',
|
||||
'tours.import.dropHint': 'Cada traçado ou rota de um arquivo vira um passeio desta viagem.',
|
||||
'tours.import.dropNoPermission': 'Você não pode adicionar passeios a esta viagem.',
|
||||
'tours.import.importing': 'Importando GPX…',
|
||||
'tours.import.importing': 'Importando…',
|
||||
'tours.days.label': 'Dias da viagem',
|
||||
'tours.days.none': 'Ainda não está em nenhum dia da viagem.',
|
||||
'tours.days.short': 'Dias {days}',
|
||||
@@ -99,7 +99,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Planejar novo passeio',
|
||||
'tours.planner.addTour': 'Adicionar passeio',
|
||||
'tours.planner.addTourHint':
|
||||
'Planeje um novo passeio, importe arquivos GPX ou um passeio do Google Maps, ou traga um passeio de outra viagem',
|
||||
'Planeje um novo passeio, importe arquivos GPX ou KML ou um passeio do Google Maps, ou traga um passeio de outra viagem',
|
||||
'tours.planner.neutralTitle': 'Planejar um passeio',
|
||||
'tours.planner.neutralBody': 'Comece um novo passeio e clique no mapa para marcar os pontos do percurso.',
|
||||
'tours.planner.newTitle': 'Novo passeio',
|
||||
@@ -238,6 +238,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Origem',
|
||||
'tours.provenance.source': 'Fonte',
|
||||
'tours.provenance.importedAt': 'Importado',
|
||||
'tours.provenance.fileImport': 'Importação {format}',
|
||||
'tours.provenance.createdAt': 'Criado',
|
||||
'tours.provenance.externalUpdated': 'Alterado no wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -792,7 +793,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Dados do mapa, não um boletim: nada sobre o que está aberto ou preparado, neve ou avalanches.',
|
||||
'tours.map.pisteFullKey': 'Legenda completa no OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Prévia do GPX',
|
||||
'tours.import.previewTitle': 'Prévia da importação',
|
||||
'tours.import.previewHint': 'Nada foi salvo ainda. Confira o percurso e depois importe.',
|
||||
'tours.import.previewFiles': '{count} arquivos',
|
||||
'tours.import.previewFiles.one': '{count} arquivo',
|
||||
@@ -801,6 +802,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} pontos',
|
||||
'tours.import.previewPoints.one': '{count} ponto',
|
||||
'tours.import.previewNoHeights': 'Sem altitudes no arquivo',
|
||||
'tours.import.previewLeftOut': 'Não importado',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Passeios são feitos só de linhas. Marcadores, áreas, sobreposições e links para outros arquivos ficam de fora, e nada é carregado de outro lugar.',
|
||||
'tours.import.leftOutPoints': '{count} marcadores',
|
||||
'tours.import.leftOutPoints.one': '{count} marcador',
|
||||
'tours.import.leftOutAreas': '{count} áreas',
|
||||
'tours.import.leftOutAreas.one': '{count} área',
|
||||
'tours.import.leftOutOther': '{count} outros itens',
|
||||
'tours.import.leftOutOther.one': '{count} outro item',
|
||||
'tours.import.unreadableIn': 'Não foi possível ler {name}: o arquivo está danificado ou não é GPX, KML nem KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Nenhum arquivo KML encontrado em {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} é grande demais depois de descompactado',
|
||||
'tours.import.previewDuplicate': 'Já está na viagem',
|
||||
'tours.import.previewAllDuplicates': 'Todas as trilhas já estão na viagem, não há nada para importar.',
|
||||
'tours.import.previewAs': 'Importar como',
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Afegeix una vista',
|
||||
'tours.title': 'Excursions',
|
||||
'tours.empty.title': 'Encara no hi ha excursions',
|
||||
'tours.empty.body': 'Planifica una excursió o importa un fitxer GPX per començar.',
|
||||
'tours.empty.body': 'Planifica una excursió o importa un fitxer GPX o KML per començar.',
|
||||
'tours.addToDay': 'Afegeix a un dia',
|
||||
'tours.addToDay.comingSoon': "Assigna excursions a un dia del viatge des de la llista d'excursions.",
|
||||
'tours.addToDay.pickDay': 'Tria un dia',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'fes-la servir amb precaució',
|
||||
'tours.caution.tooltip': 'Altitud o mètriques incompletes: importada amb reserves',
|
||||
'tours.import.caution': 'Importada amb reserves: altitud o mètriques incompletes',
|
||||
'tours.import.success': '{count} excursions importades de GPX',
|
||||
'tours.import.success.one': '{count} excursió importada de GPX',
|
||||
'tours.import.success': '{count} excursions importades',
|
||||
'tours.import.success.one': '{count} excursió importada',
|
||||
'tours.import.noTrackIn': "No s'ha trobat cap traça ni ruta a {name}",
|
||||
'tours.import.errorIn': "No s'ha pogut importar {name}",
|
||||
'tours.import.button': 'Importa GPX',
|
||||
'tours.import.hint': "Importa fitxers GPX com a excursions d'aquest viatge",
|
||||
'tours.import.unsupported': 'Només es poden importar fitxers GPX com a excursions.',
|
||||
'tours.import.button': 'Importa GPX o KML',
|
||||
'tours.import.hint': "Importa fitxers GPX, KML o KMZ com a excursions d'aquest viatge",
|
||||
'tours.import.unsupported': 'Només es poden importar fitxers GPX, KML i KMZ com a excursions.',
|
||||
'tours.import.skipped': "{count} traces ja eren en aquest viatge i s'han omès",
|
||||
'tours.import.skipped.one': "{count} traça ja era en aquest viatge i s'ha omès",
|
||||
'tours.import.allSkipped': 'Tot el que hi ha en aquest fitxer ja és en aquest viatge.',
|
||||
'tours.import.drop': 'Deixa anar fitxers GPX per importar-los',
|
||||
'tours.import.drop': 'Deixa anar fitxers GPX, KML o KMZ per importar-los',
|
||||
'tours.import.dropHint': "Cada traça o ruta d'un fitxer es converteix en una excursió d'aquest viatge.",
|
||||
'tours.import.dropNoPermission': 'No pots afegir excursions a aquest viatge.',
|
||||
'tours.import.importing': "S'està important el GPX…",
|
||||
'tours.import.importing': "S'està important…",
|
||||
'tours.days.label': 'Dies del viatge',
|
||||
'tours.days.none': 'Encara no és a cap dia del viatge.',
|
||||
'tours.days.short': 'Dies {days}',
|
||||
@@ -99,7 +99,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Planifica una nova excursió',
|
||||
'tours.planner.addTour': 'Afegeix una ruta',
|
||||
'tours.planner.addTourHint':
|
||||
"Planifica una ruta nova, importa fitxers GPX o un passeig de Google Maps, o porta una ruta d'un altre viatge",
|
||||
"Planifica una ruta nova, importa fitxers GPX o KML o un passeig de Google Maps, o porta una ruta d'un altre viatge",
|
||||
'tours.planner.neutralTitle': 'Planifica una excursió',
|
||||
'tours.planner.neutralBody': 'Comença una ruta nova i fes clic al mapa per marcar-ne els punts.',
|
||||
'tours.planner.newTitle': 'Nova excursió',
|
||||
@@ -237,6 +237,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Origen',
|
||||
'tours.provenance.source': 'Font',
|
||||
'tours.provenance.importedAt': 'Importat',
|
||||
'tours.provenance.fileImport': 'Importació {format}',
|
||||
'tours.provenance.createdAt': 'Creat',
|
||||
'tours.provenance.externalUpdated': 'Modificat a wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -794,7 +795,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteLift': 'Remuntadors',
|
||||
'tours.map.pisteStatic': 'Dades del mapa, no un informe: res sobre què és obert o trepitjat, la neu o les allaus.',
|
||||
'tours.map.pisteFullKey': 'Llegenda completa a OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Previsualització del GPX',
|
||||
'tours.import.previewTitle': 'Previsualització de la importació',
|
||||
'tours.import.previewHint': 'Encara no s’ha desat res. Revisa el recorregut i després importa.',
|
||||
'tours.import.previewFiles': '{count} fitxers',
|
||||
'tours.import.previewFiles.one': '{count} fitxer',
|
||||
@@ -803,6 +804,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} punts',
|
||||
'tours.import.previewPoints.one': '{count} punt',
|
||||
'tours.import.previewNoHeights': 'Sense altituds al fitxer',
|
||||
'tours.import.previewLeftOut': 'No importat',
|
||||
'tours.import.previewLeftOutHint':
|
||||
"Les excursions només es fan de línies. Les xinxetes, àrees, superposicions i enllaços a altres fitxers queden fora, i no es carrega res d'enlloc més.",
|
||||
'tours.import.leftOutPoints': '{count} xinxetes',
|
||||
'tours.import.leftOutPoints.one': '{count} xinxeta',
|
||||
'tours.import.leftOutAreas': '{count} àrees',
|
||||
'tours.import.leftOutAreas.one': '{count} àrea',
|
||||
'tours.import.leftOutOther': '{count} elements més',
|
||||
'tours.import.leftOutOther.one': '{count} element més',
|
||||
'tours.import.unreadableIn': "No s'ha pogut llegir {name}: el fitxer està malmès o no és GPX, KML ni KMZ",
|
||||
'tours.import.archiveNoKmlIn': "No s'ha trobat cap fitxer KML a {name}",
|
||||
'tours.import.archiveTooLargeIn': '{name} és massa gran un cop descomprimit',
|
||||
'tours.import.previewDuplicate': 'Ja és al viatge',
|
||||
'tours.import.previewAllDuplicates': 'Tots els tracks ja són al viatge, no hi ha res a importar.',
|
||||
'tours.import.previewAs': 'Importa com a',
|
||||
|
||||
+27
-11
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Zobrazení pro přidávání',
|
||||
'tours.title': 'Výlety',
|
||||
'tours.empty.title': 'Zatím žádné výlety',
|
||||
'tours.empty.body': 'Začněte naplánováním výletu nebo importem souboru GPX.',
|
||||
'tours.empty.body': 'Začněte naplánováním výletu nebo importem souboru GPX či KML.',
|
||||
'tours.addToDay': 'Přidat ke dni',
|
||||
'tours.addToDay.comingSoon': 'Přiřaďte výlety ke dni cesty ze seznamu výletů.',
|
||||
'tours.addToDay.pickDay': 'Vyberte den',
|
||||
@@ -20,22 +20,22 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'používejte s opatrností',
|
||||
'tours.caution.tooltip': 'Neúplné výškové údaje nebo metriky – importováno s výhradou',
|
||||
'tours.import.caution': 'Importováno s výhradou – neúplné výškové údaje nebo metriky',
|
||||
'tours.import.success': 'Z GPX bylo importováno {count} výletů',
|
||||
'tours.import.success.one': 'Z GPX byl importován {count} výlet',
|
||||
'tours.import.success.few': 'Z GPX byly importovány {count} výlety',
|
||||
'tours.import.success': 'Importováno {count} výletů',
|
||||
'tours.import.success.one': 'Importován {count} výlet',
|
||||
'tours.import.success.few': 'Importovány {count} výlety',
|
||||
'tours.import.noTrackIn': 'V souboru {name} nebyla nalezena stopa ani trasa',
|
||||
'tours.import.errorIn': 'Soubor {name} se nepodařilo importovat',
|
||||
'tours.import.button': 'Importovat GPX',
|
||||
'tours.import.hint': 'Importujte soubory GPX jako výlety této cesty',
|
||||
'tours.import.unsupported': 'Jako výlety lze importovat pouze soubory GPX.',
|
||||
'tours.import.button': 'Importovat GPX nebo KML',
|
||||
'tours.import.hint': 'Importujte soubory GPX, KML nebo KMZ jako výlety této cesty',
|
||||
'tours.import.unsupported': 'Jako výlety lze importovat pouze soubory GPX, KML a KMZ.',
|
||||
'tours.import.skipped': '{count} stop už v této cestě bylo, proto byly přeskočeny',
|
||||
'tours.import.skipped.one': '{count} stopa už v této cestě byla, proto byla přeskočena',
|
||||
'tours.import.skipped.few': '{count} stopy už v této cestě byly, proto byly přeskočeny',
|
||||
'tours.import.allSkipped': 'Vše z tohoto souboru už v této cestě je.',
|
||||
'tours.import.drop': 'Pusťte sem soubory GPX a importujte je',
|
||||
'tours.import.drop': 'Pusťte sem soubory GPX, KML nebo KMZ a importujte je',
|
||||
'tours.import.dropHint': 'Každá stopa nebo trasa v souboru se stane výletem této cesty.',
|
||||
'tours.import.dropNoPermission': 'Do této cesty nemůžete přidávat výlety.',
|
||||
'tours.import.importing': 'Importování GPX…',
|
||||
'tours.import.importing': 'Importování…',
|
||||
'tours.days.label': 'Dny cesty',
|
||||
'tours.days.none': 'Zatím není přiřazen k žádnému dni cesty.',
|
||||
'tours.days.short': 'Dny {days}',
|
||||
@@ -99,7 +99,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Naplánovat nový výlet',
|
||||
'tours.planner.addTour': 'Přidat túru',
|
||||
'tours.planner.addTourHint':
|
||||
'Naplánuj novou túru, importuj soubory GPX nebo procházku z Google Maps, nebo převezmi túru z jiné cesty',
|
||||
'Naplánuj novou túru, importuj soubory GPX nebo KML či procházku z Google Maps, nebo převezmi túru z jiné cesty',
|
||||
'tours.planner.neutralTitle': 'Naplánovat výlet',
|
||||
'tours.planner.neutralBody': 'Začněte novou túru a kliknutím do mapy umístěte její body.',
|
||||
'tours.planner.newTitle': 'Nový výlet',
|
||||
@@ -235,6 +235,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Původ',
|
||||
'tours.provenance.source': 'Zdroj',
|
||||
'tours.provenance.importedAt': 'Importováno',
|
||||
'tours.provenance.fileImport': 'Import {format}',
|
||||
'tours.provenance.createdAt': 'Vytvořeno',
|
||||
'tours.provenance.externalUpdated': 'Změněno ve wanderer',
|
||||
'tours.provenance.externalUpdatedHint': 'Datum změny, které wanderer uvedl při importu. Vodítko, ne důkaz verze.',
|
||||
@@ -779,7 +780,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Mapová data, ne zpravodajství: nic o tom, co je otevřené nebo upravené, o sněhu ani lavinách.',
|
||||
'tours.map.pisteFullKey': 'Celá legenda na OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Náhled GPX',
|
||||
'tours.import.previewTitle': 'Náhled importu',
|
||||
'tours.import.previewHint': 'Zatím nic není uloženo. Zkontrolujte průběh a pak importujte.',
|
||||
'tours.import.previewFiles': '{count} souborů',
|
||||
'tours.import.previewFiles.one': '{count} soubor',
|
||||
@@ -791,6 +792,21 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints.one': '{count} bod',
|
||||
'tours.import.previewPoints.few': '{count} body',
|
||||
'tours.import.previewNoHeights': 'V souboru nejsou výšky',
|
||||
'tours.import.previewLeftOut': 'Neimportováno',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Výlety vznikají jen z čar. Špendlíky, plochy, překryvy a odkazy na jiné soubory zůstávají stranou a nic se nenačítá odjinud.',
|
||||
'tours.import.leftOutPoints': '{count} špendlíků',
|
||||
'tours.import.leftOutPoints.one': '{count} špendlík',
|
||||
'tours.import.leftOutPoints.few': '{count} špendlíky',
|
||||
'tours.import.leftOutAreas': '{count} ploch',
|
||||
'tours.import.leftOutAreas.one': '{count} plocha',
|
||||
'tours.import.leftOutAreas.few': '{count} plochy',
|
||||
'tours.import.leftOutOther': '{count} dalších položek',
|
||||
'tours.import.leftOutOther.one': '{count} další položka',
|
||||
'tours.import.leftOutOther.few': '{count} další položky',
|
||||
'tours.import.unreadableIn': 'Soubor {name} nelze přečíst: je poškozený nebo to není GPX, KML ani KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'V souboru {name} není žádný soubor KML',
|
||||
'tours.import.archiveTooLargeIn': 'Soubor {name} je po rozbalení příliš velký',
|
||||
'tours.import.previewDuplicate': 'Už je v cestě',
|
||||
'tours.import.previewAllDuplicates': 'Všechny trasy už v cestě jsou, není co importovat.',
|
||||
'tours.import.previewAs': 'Importovat jako',
|
||||
|
||||
+24
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Ansicht zum Hinzufügen',
|
||||
'tours.title': 'Touren',
|
||||
'tours.empty.title': 'Noch keine Touren',
|
||||
'tours.empty.body': 'Plane eine Tour oder importiere eine GPX-Datei.',
|
||||
'tours.empty.body': 'Plane eine Tour oder importiere eine GPX- oder KML-Datei.',
|
||||
'tours.addToDay': 'Zum Tag hinzufügen',
|
||||
'tours.addToDay.comingSoon': 'Ordne Touren über die Tourenliste einem Reisetag zu.',
|
||||
'tours.addToDay.pickDay': 'Tag auswählen',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'mit Vorsicht verwenden',
|
||||
'tours.caution.tooltip': 'Höhendaten oder Streckenwerte unvollständig – mit Vorsicht importiert',
|
||||
'tours.import.caution': 'Mit Vorsicht importiert – Höhendaten oder Streckenwerte unvollständig',
|
||||
'tours.import.success': '{count} Touren aus GPX importiert',
|
||||
'tours.import.success.one': '{count} Tour aus GPX importiert',
|
||||
'tours.import.success': '{count} Touren importiert',
|
||||
'tours.import.success.one': '{count} Tour importiert',
|
||||
'tours.import.noTrackIn': 'Keine Strecke oder Route in {name} gefunden',
|
||||
'tours.import.errorIn': '{name} konnte nicht importiert werden',
|
||||
'tours.import.button': 'GPX importieren',
|
||||
'tours.import.hint': 'GPX-Dateien als Touren dieser Reise importieren',
|
||||
'tours.import.unsupported': 'Nur GPX-Dateien können als Touren importiert werden.',
|
||||
'tours.import.button': 'GPX oder KML importieren',
|
||||
'tours.import.hint': 'GPX-, KML- oder KMZ-Dateien als Touren dieser Reise importieren',
|
||||
'tours.import.unsupported': 'Nur GPX-, KML- und KMZ-Dateien können als Touren importiert werden.',
|
||||
'tours.import.skipped': '{count} Strecken waren schon in dieser Reise und wurden übersprungen',
|
||||
'tours.import.skipped.one': '{count} Strecke war schon in dieser Reise und wurde übersprungen',
|
||||
'tours.import.allSkipped': 'Alles aus dieser Datei ist schon in dieser Reise.',
|
||||
'tours.import.drop': 'GPX-Dateien hier ablegen, um sie zu importieren',
|
||||
'tours.import.drop': 'GPX-, KML- oder KMZ-Dateien hier ablegen, um sie zu importieren',
|
||||
'tours.import.dropHint': 'Jede Strecke oder Route in einer Datei wird zu einer Tour dieser Reise.',
|
||||
'tours.import.dropNoPermission': 'Du kannst dieser Reise keine Touren hinzufügen.',
|
||||
'tours.import.importing': 'GPX wird importiert…',
|
||||
'tours.import.importing': 'Wird importiert…',
|
||||
'tours.days.label': 'Reisetage',
|
||||
'tours.days.none': 'Noch an keinem Tag der Reise.',
|
||||
'tours.days.short': 'Tage {days}',
|
||||
@@ -99,7 +99,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Neue Tour planen',
|
||||
'tours.planner.addTour': 'Tour hinzufügen',
|
||||
'tours.planner.addTourHint':
|
||||
'Neue Tour planen, GPX-Dateien oder einen Walk aus Google Maps importieren oder eine Tour aus einer anderen Reise übernehmen',
|
||||
'Neue Tour planen, GPX- oder KML-Dateien oder einen Walk aus Google Maps importieren oder eine Tour aus einer anderen Reise übernehmen',
|
||||
'tours.planner.neutralTitle': 'Tour planen',
|
||||
'tours.planner.neutralBody': 'Starte eine neue Tour und klicke auf die Karte, um die Wegpunkte zu setzen.',
|
||||
'tours.planner.newTitle': 'Neue Tour',
|
||||
@@ -239,6 +239,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Herkunft',
|
||||
'tours.provenance.source': 'Quelle',
|
||||
'tours.provenance.importedAt': 'Importiert',
|
||||
'tours.provenance.fileImport': '{format}-Import',
|
||||
'tours.provenance.createdAt': 'Angelegt',
|
||||
'tours.provenance.externalUpdated': 'Geändert bei wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -800,7 +801,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Kartendaten, kein Lagebericht: nichts dazu, was offen oder präpariert ist, zu Schnee oder Lawinen.',
|
||||
'tours.map.pisteFullKey': 'Ganze Legende bei OpenSnowMap',
|
||||
'tours.import.previewTitle': 'GPX-Vorschau',
|
||||
'tours.import.previewTitle': 'Import-Vorschau',
|
||||
'tours.import.previewHint': 'Noch ist nichts gespeichert. Prüfe den Verlauf, dann importieren.',
|
||||
'tours.import.previewFiles': '{count} Dateien',
|
||||
'tours.import.previewFiles.one': '{count} Datei',
|
||||
@@ -809,6 +810,19 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} Punkte',
|
||||
'tours.import.previewPoints.one': '{count} Punkt',
|
||||
'tours.import.previewNoHeights': 'Keine Höhen in der Datei',
|
||||
'tours.import.previewLeftOut': 'Nicht importiert',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Touren entstehen nur aus Linien. Pins, Flächen, Overlays und Verweise auf andere Dateien bleiben draußen, und nichts wird von anderswo geladen.',
|
||||
'tours.import.leftOutPoints': '{count} Pins',
|
||||
'tours.import.leftOutPoints.one': '{count} Pin',
|
||||
'tours.import.leftOutAreas': '{count} Flächen',
|
||||
'tours.import.leftOutAreas.one': '{count} Fläche',
|
||||
'tours.import.leftOutOther': '{count} weitere Elemente',
|
||||
'tours.import.leftOutOther.one': '{count} weiteres Element',
|
||||
'tours.import.unreadableIn':
|
||||
'{name} konnte nicht gelesen werden: Die Datei ist beschädigt oder kein GPX, KML oder KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Keine KML-Datei in {name} gefunden',
|
||||
'tours.import.archiveTooLargeIn': '{name} ist entpackt zu groß',
|
||||
'tours.import.previewDuplicate': 'Schon in der Reise',
|
||||
'tours.import.previewAllDuplicates': 'Alle Tracks sind schon in der Reise, es gibt nichts zu importieren.',
|
||||
'tours.import.previewAs': 'Importieren als',
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Add view',
|
||||
'tours.title': 'Tours',
|
||||
'tours.empty.title': 'No tours yet',
|
||||
'tours.empty.body': 'Plan a tour or import a GPX file to get started.',
|
||||
'tours.empty.body': 'Plan a tour or import a GPX or KML file to get started.',
|
||||
'tours.addToDay': 'Add to day',
|
||||
'tours.addToDay.comingSoon': 'Assign tours to a trip day from the tour list.',
|
||||
'tours.addToDay.pickDay': 'Pick a day',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'use with caution',
|
||||
'tours.caution.tooltip': 'Elevation/metrics incomplete — imported with caution',
|
||||
'tours.import.caution': 'Imported with caution — elevation/metrics incomplete',
|
||||
'tours.import.success': '{count} tours imported from GPX',
|
||||
'tours.import.success.one': '{count} tour imported from GPX',
|
||||
'tours.import.success': '{count} tours imported',
|
||||
'tours.import.success.one': '{count} tour imported',
|
||||
'tours.import.noTrackIn': 'No track or route found in {name}',
|
||||
'tours.import.errorIn': 'Could not import {name}',
|
||||
'tours.import.button': 'Import GPX',
|
||||
'tours.import.hint': 'Import GPX files as tours of this trip',
|
||||
'tours.import.unsupported': 'Only GPX files can be imported as tours.',
|
||||
'tours.import.button': 'Import GPX or KML',
|
||||
'tours.import.hint': 'Import GPX, KML or KMZ files as tours of this trip',
|
||||
'tours.import.unsupported': 'Only GPX, KML and KMZ files can be imported as tours.',
|
||||
'tours.import.skipped': '{count} tracks were already in this trip and were skipped',
|
||||
'tours.import.skipped.one': '{count} track was already in this trip and was skipped',
|
||||
'tours.import.allSkipped': 'Everything in that file is already in this trip.',
|
||||
'tours.import.drop': 'Drop GPX files to import them',
|
||||
'tours.import.drop': 'Drop GPX, KML or KMZ files to import them',
|
||||
'tours.import.dropHint': 'Every track or route in a file becomes a tour of this trip.',
|
||||
'tours.import.dropNoPermission': 'You cannot add tours to this trip.',
|
||||
'tours.import.importing': 'Importing GPX…',
|
||||
'tours.import.importing': 'Importing…',
|
||||
'tours.days.label': 'Trip days',
|
||||
'tours.days.none': 'Not on any day of the trip yet.',
|
||||
'tours.days.short': 'Days {days}',
|
||||
@@ -95,7 +95,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Plan new tour',
|
||||
'tours.planner.addTour': 'Add tour',
|
||||
'tours.planner.addTourHint':
|
||||
'Plan a new tour, import GPX files or a Google Maps walk, or take a tour over from another trip',
|
||||
'Plan a new tour, import GPX or KML files or a Google Maps walk, or take a tour over from another trip',
|
||||
'tours.planner.neutralTitle': 'Plan a tour',
|
||||
'tours.planner.neutralBody': 'Start a new tour and click the map to place its waypoints.',
|
||||
'tours.planner.newTitle': 'New tour',
|
||||
@@ -231,6 +231,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Origin',
|
||||
'tours.provenance.source': 'Source',
|
||||
'tours.provenance.importedAt': 'Imported',
|
||||
'tours.provenance.fileImport': '{format} import',
|
||||
'tours.provenance.createdAt': 'Created',
|
||||
'tours.provenance.externalUpdated': 'Changed on wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -779,7 +780,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Mapped data, not a live report: it says nothing about what is open or groomed, the snow or avalanches.',
|
||||
'tours.map.pisteFullKey': 'Full key at OpenSnowMap',
|
||||
'tours.import.previewTitle': 'GPX preview',
|
||||
'tours.import.previewTitle': 'Import preview',
|
||||
'tours.import.previewHint': 'Nothing is saved yet. Check the course, then import.',
|
||||
'tours.import.previewFiles': '{count} files',
|
||||
'tours.import.previewFiles.one': '{count} file',
|
||||
@@ -788,6 +789,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} points',
|
||||
'tours.import.previewPoints.one': '{count} point',
|
||||
'tours.import.previewNoHeights': 'No heights in the file',
|
||||
'tours.import.previewLeftOut': 'Not imported',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Tours are made of lines only. Pins, areas, overlays and links to other files stay out, and nothing is loaded from elsewhere.',
|
||||
'tours.import.leftOutPoints': '{count} pins',
|
||||
'tours.import.leftOutPoints.one': '{count} pin',
|
||||
'tours.import.leftOutAreas': '{count} areas',
|
||||
'tours.import.leftOutAreas.one': '{count} area',
|
||||
'tours.import.leftOutOther': '{count} other items',
|
||||
'tours.import.leftOutOther.one': '{count} other item',
|
||||
'tours.import.unreadableIn': 'Could not read {name}: the file is damaged or not GPX, KML or KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'No KML file found in {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} is too large once unpacked',
|
||||
'tours.import.previewDuplicate': 'Already in the trip',
|
||||
'tours.import.previewAllDuplicates': 'Every track is already in the trip, so there is nothing to import.',
|
||||
'tours.import.previewAs': 'Import as',
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Añadir vista',
|
||||
'tours.title': 'Excursiones',
|
||||
'tours.empty.title': 'Aún no hay excursiones',
|
||||
'tours.empty.body': 'Planifica una excursión o importa un archivo GPX para empezar.',
|
||||
'tours.empty.body': 'Planifica una excursión o importa un archivo GPX o KML para empezar.',
|
||||
'tours.addToDay': 'Añadir a un día',
|
||||
'tours.addToDay.comingSoon': 'Asigna excursiones a un día del viaje desde la lista de excursiones.',
|
||||
'tours.addToDay.pickDay': 'Elige un día',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'usar con precaución',
|
||||
'tours.caution.tooltip': 'Altitud o métricas incompletas: importada con reservas',
|
||||
'tours.import.caution': 'Importada con reservas: altitud o métricas incompletas',
|
||||
'tours.import.success': '{count} excursiones importadas desde GPX',
|
||||
'tours.import.success.one': '{count} excursión importada desde GPX',
|
||||
'tours.import.success': '{count} excursiones importadas',
|
||||
'tours.import.success.one': '{count} excursión importada',
|
||||
'tours.import.noTrackIn': 'No se ha encontrado ningún track ni ruta en {name}',
|
||||
'tours.import.errorIn': 'No se ha podido importar {name}',
|
||||
'tours.import.button': 'Importar GPX',
|
||||
'tours.import.hint': 'Importa archivos GPX como excursiones de este viaje',
|
||||
'tours.import.unsupported': 'Solo se pueden importar archivos GPX como excursiones.',
|
||||
'tours.import.button': 'Importar GPX o KML',
|
||||
'tours.import.hint': 'Importa archivos GPX, KML o KMZ como excursiones de este viaje',
|
||||
'tours.import.unsupported': 'Solo se pueden importar archivos GPX, KML y KMZ como excursiones.',
|
||||
'tours.import.skipped': '{count} tracks ya estaban en este viaje y se han omitido',
|
||||
'tours.import.skipped.one': '{count} track ya estaba en este viaje y se ha omitido',
|
||||
'tours.import.allSkipped': 'Todo lo de ese archivo ya está en este viaje.',
|
||||
'tours.import.drop': 'Suelta archivos GPX para importarlos',
|
||||
'tours.import.drop': 'Suelta archivos GPX, KML o KMZ para importarlos',
|
||||
'tours.import.dropHint': 'Cada track o ruta de un archivo se convierte en una excursión de este viaje.',
|
||||
'tours.import.dropNoPermission': 'No puedes añadir excursiones a este viaje.',
|
||||
'tours.import.importing': 'Importando GPX…',
|
||||
'tours.import.importing': 'Importando…',
|
||||
'tours.days.label': 'Días del viaje',
|
||||
'tours.days.none': 'Aún no está en ningún día del viaje.',
|
||||
'tours.days.short': 'Días {days}',
|
||||
@@ -100,7 +100,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Planificar nueva excursión',
|
||||
'tours.planner.addTour': 'Añadir ruta',
|
||||
'tours.planner.addTourHint':
|
||||
'Planifica una ruta nueva, importa archivos GPX o un paseo de Google Maps, o trae una ruta de otro viaje',
|
||||
'Planifica una ruta nueva, importa archivos GPX o KML o un paseo de Google Maps, o trae una ruta de otro viaje',
|
||||
'tours.planner.neutralTitle': 'Planificar una excursión',
|
||||
'tours.planner.neutralBody': 'Empieza una ruta nueva y haz clic en el mapa para colocar sus puntos.',
|
||||
'tours.planner.newTitle': 'Nueva excursión',
|
||||
@@ -237,6 +237,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Origen',
|
||||
'tours.provenance.source': 'Fuente',
|
||||
'tours.provenance.importedAt': 'Importado',
|
||||
'tours.provenance.fileImport': 'Importación {format}',
|
||||
'tours.provenance.createdAt': 'Creado',
|
||||
'tours.provenance.externalUpdated': 'Modificado en wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -792,7 +793,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteLift': 'Remontes',
|
||||
'tours.map.pisteStatic': 'Datos del mapa, no un parte: no dice qué está abierto o pisado, ni nada de nieve o aludes.',
|
||||
'tours.map.pisteFullKey': 'Leyenda completa en OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Vista previa del GPX',
|
||||
'tours.import.previewTitle': 'Vista previa de la importación',
|
||||
'tours.import.previewHint': 'Aún no se ha guardado nada. Revisa el recorrido y luego importa.',
|
||||
'tours.import.previewFiles': '{count} archivos',
|
||||
'tours.import.previewFiles.one': '{count} archivo',
|
||||
@@ -801,6 +802,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} puntos',
|
||||
'tours.import.previewPoints.one': '{count} punto',
|
||||
'tours.import.previewNoHeights': 'Sin altitudes en el archivo',
|
||||
'tours.import.previewLeftOut': 'No importado',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Las excursiones solo se forman con líneas. Los marcadores, áreas, superposiciones y enlaces a otros archivos quedan fuera, y no se carga nada de otro sitio.',
|
||||
'tours.import.leftOutPoints': '{count} marcadores',
|
||||
'tours.import.leftOutPoints.one': '{count} marcador',
|
||||
'tours.import.leftOutAreas': '{count} áreas',
|
||||
'tours.import.leftOutAreas.one': '{count} área',
|
||||
'tours.import.leftOutOther': '{count} elementos más',
|
||||
'tours.import.leftOutOther.one': '{count} elemento más',
|
||||
'tours.import.unreadableIn': 'No se ha podido leer {name}: el archivo está dañado o no es GPX, KML ni KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'No hay ningún archivo KML en {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} es demasiado grande una vez descomprimido',
|
||||
'tours.import.previewDuplicate': 'Ya está en el viaje',
|
||||
'tours.import.previewAllDuplicates': 'Todos los tracks ya están en el viaje, no hay nada que importar.',
|
||||
'tours.import.previewAs': 'Importar como',
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Lisamise vaade',
|
||||
'tours.title': 'Retked',
|
||||
'tours.empty.title': 'Retki veel pole',
|
||||
'tours.empty.body': 'Alustamiseks planeeri retk või impordi GPX-fail.',
|
||||
'tours.empty.body': 'Alustamiseks planeeri retk või impordi GPX- või KML-fail.',
|
||||
'tours.addToDay': 'Lisa päevale',
|
||||
'tours.addToDay.comingSoon': 'Lisa retkede loendist retki reisipäevale.',
|
||||
'tours.addToDay.pickDay': 'Vali päev',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'Kasuta ettevaatlikult',
|
||||
'tours.caution.tooltip': 'Kõrgusandmed või mõõdikud on puudulikud — imporditud hoiatusega',
|
||||
'tours.import.caution': 'Imporditud hoiatusega — kõrgusandmed või mõõdikud on puudulikud',
|
||||
'tours.import.success': 'GPX-failist imporditi {count} retke',
|
||||
'tours.import.success.one': 'GPX-failist imporditi {count} retk',
|
||||
'tours.import.success': '{count} retke imporditud',
|
||||
'tours.import.success.one': '{count} retk imporditud',
|
||||
'tours.import.noTrackIn': 'Failist {name} ei leitud rada ega marsruuti',
|
||||
'tours.import.errorIn': 'Faili {name} importimine ebaõnnestus',
|
||||
'tours.import.button': 'Impordi GPX',
|
||||
'tours.import.hint': 'Impordi GPX-failid selle reisi retkedena',
|
||||
'tours.import.unsupported': 'Retkedena saab importida ainult GPX-faile.',
|
||||
'tours.import.button': 'Impordi GPX või KML',
|
||||
'tours.import.hint': 'Impordi GPX-, KML- või KMZ-failid selle reisi retkedena',
|
||||
'tours.import.unsupported': 'Retkedena saab importida ainult GPX-, KML- ja KMZ-faile.',
|
||||
'tours.import.skipped': '{count} rada olid juba selles reisis ja jäeti vahele',
|
||||
'tours.import.skipped.one': '{count} rada oli juba selles reisis ja jäeti vahele',
|
||||
'tours.import.allSkipped': 'Kõik selle faili sisu on juba selles reisis.',
|
||||
'tours.import.drop': 'Lohista GPX-failid siia, et need importida',
|
||||
'tours.import.drop': 'Lohista GPX-, KML- või KMZ-failid siia, et need importida',
|
||||
'tours.import.dropHint': 'Faili iga rajast või marsruudist saab selle reisi retk.',
|
||||
'tours.import.dropNoPermission': 'Sa ei saa sellele reisile retki lisada.',
|
||||
'tours.import.importing': 'GPX-i importimine…',
|
||||
'tours.import.importing': 'Importimine…',
|
||||
'tours.days.label': 'Reisipäevad',
|
||||
'tours.days.none': 'Pole veel ühelegi reisipäevale lisatud.',
|
||||
'tours.days.short': 'Päevad {days}',
|
||||
@@ -96,7 +96,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Planeeri uus retk',
|
||||
'tours.planner.addTour': 'Lisa matk',
|
||||
'tours.planner.addTourHint':
|
||||
'Planeeri uus matk, impordi GPX-faile või Google Mapsi jalutuskäik või võta matk üle teiselt reisilt',
|
||||
'Planeeri uus matk, impordi GPX- või KML-faile või Google Mapsi jalutuskäik või võta matk üle teiselt reisilt',
|
||||
'tours.planner.neutralTitle': 'Planeeri retk',
|
||||
'tours.planner.neutralBody': 'Alusta uut matka ja klõpsa kaardil, et selle punktid paigutada.',
|
||||
'tours.planner.newTitle': 'Uus retk',
|
||||
@@ -235,6 +235,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Päritolu',
|
||||
'tours.provenance.source': 'Allikas',
|
||||
'tours.provenance.importedAt': 'Imporditud',
|
||||
'tours.provenance.fileImport': '{format}-import',
|
||||
'tours.provenance.createdAt': 'Loodud',
|
||||
'tours.provenance.externalUpdated': 'Muudetud wandereris',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -783,7 +784,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Kaardiandmed, mitte olukorrateade: ei ütle midagi lahtiolekust, hooldusest, lumest ega laviinidest.',
|
||||
'tours.map.pisteFullKey': 'Täielik legend OpenSnowMapis',
|
||||
'tours.import.previewTitle': 'GPX eelvaade',
|
||||
'tours.import.previewTitle': 'Impordi eelvaade',
|
||||
'tours.import.previewHint': 'Midagi pole veel salvestatud. Kontrolli marsruuti ja impordi siis.',
|
||||
'tours.import.previewFiles': '{count} faili',
|
||||
'tours.import.previewFiles.one': '{count} fail',
|
||||
@@ -792,6 +793,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} punkti',
|
||||
'tours.import.previewPoints.one': '{count} punkt',
|
||||
'tours.import.previewNoHeights': 'Failis pole kõrgusi',
|
||||
'tours.import.previewLeftOut': 'Ei impordita',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Retked tehakse ainult joontest. Nööpnõelad, alad, kihid ja lingid teistele failidele jäävad välja ning midagi ei laadita mujalt.',
|
||||
'tours.import.leftOutPoints': '{count} nööpnõela',
|
||||
'tours.import.leftOutPoints.one': '{count} nööpnõel',
|
||||
'tours.import.leftOutAreas': '{count} ala',
|
||||
'tours.import.leftOutAreas.one': '{count} ala',
|
||||
'tours.import.leftOutOther': '{count} muud elementi',
|
||||
'tours.import.leftOutOther.one': '{count} muu element',
|
||||
'tours.import.unreadableIn': 'Faili {name} ei saanud lugeda: see on vigane või pole GPX, KML ega KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Failis {name} pole KML-faili',
|
||||
'tours.import.archiveTooLargeIn': 'Fail {name} on lahtipakituna liiga suur',
|
||||
'tours.import.previewDuplicate': 'Juba reisil',
|
||||
'tours.import.previewAllDuplicates': 'Kõik rajad on juba reisil, pole midagi importida.',
|
||||
'tours.import.previewAs': 'Impordi kui',
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Ajouter une vue',
|
||||
'tours.title': 'Sorties',
|
||||
'tours.empty.title': 'Aucune sortie pour le moment',
|
||||
'tours.empty.body': 'Planifiez une sortie ou importez un fichier GPX pour commencer.',
|
||||
'tours.empty.body': 'Planifiez une sortie ou importez un fichier GPX ou KML pour commencer.',
|
||||
'tours.addToDay': 'Ajouter à une journée',
|
||||
'tours.addToDay.comingSoon': 'Affectez les sorties à une journée du voyage depuis la liste des sorties.',
|
||||
'tours.addToDay.pickDay': 'Choisir une journée',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'à utiliser avec prudence',
|
||||
'tours.caution.tooltip': 'Altitude ou mesures incomplètes : importée avec réserves',
|
||||
'tours.import.caution': 'Importée avec réserves : altitude ou mesures incomplètes',
|
||||
'tours.import.success': '{count} sorties importées depuis GPX',
|
||||
'tours.import.success.one': '{count} sortie importée depuis GPX',
|
||||
'tours.import.success': '{count} sorties importées',
|
||||
'tours.import.success.one': '{count} sortie importée',
|
||||
'tours.import.noTrackIn': 'Aucune trace ni aucun itinéraire trouvé dans {name}',
|
||||
'tours.import.errorIn': "Impossible d'importer {name}",
|
||||
'tours.import.button': 'Importer un GPX',
|
||||
'tours.import.hint': 'Importer des fichiers GPX comme sorties de ce voyage',
|
||||
'tours.import.unsupported': 'Seuls les fichiers GPX peuvent être importés comme sorties.',
|
||||
'tours.import.button': 'Importer un GPX ou KML',
|
||||
'tours.import.hint': 'Importer des fichiers GPX, KML ou KMZ comme sorties de ce voyage',
|
||||
'tours.import.unsupported': 'Seuls les fichiers GPX, KML et KMZ peuvent être importés comme sorties.',
|
||||
'tours.import.skipped': '{count} traces étaient déjà dans ce voyage et ont été ignorées',
|
||||
'tours.import.skipped.one': '{count} trace était déjà dans ce voyage et a été ignorée',
|
||||
'tours.import.allSkipped': 'Tout le contenu de ce fichier est déjà dans ce voyage.',
|
||||
'tours.import.drop': 'Déposez des fichiers GPX pour les importer',
|
||||
'tours.import.drop': 'Déposez des fichiers GPX, KML ou KMZ pour les importer',
|
||||
'tours.import.dropHint': "Chaque trace ou itinéraire d'un fichier devient une sortie de ce voyage.",
|
||||
'tours.import.dropNoPermission': 'Vous ne pouvez pas ajouter de sorties à ce voyage.',
|
||||
'tours.import.importing': 'Import du GPX…',
|
||||
'tours.import.importing': 'Import en cours…',
|
||||
'tours.days.label': 'Jours du voyage',
|
||||
'tours.days.none': "Sur aucun jour du voyage pour l'instant.",
|
||||
'tours.days.short': 'Jours {days}',
|
||||
@@ -101,7 +101,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Planifier une nouvelle sortie',
|
||||
'tours.planner.addTour': 'Ajouter une sortie',
|
||||
'tours.planner.addTourHint':
|
||||
"Planifier une nouvelle sortie, importer des fichiers GPX ou une balade Google Maps, ou reprendre une sortie d'un autre voyage",
|
||||
"Planifier une nouvelle sortie, importer des fichiers GPX ou KML ou une balade Google Maps, ou reprendre une sortie d'un autre voyage",
|
||||
'tours.planner.neutralTitle': 'Planifier une sortie',
|
||||
'tours.planner.neutralBody': 'Commencez une nouvelle randonnée et cliquez sur la carte pour placer ses points.',
|
||||
'tours.planner.newTitle': 'Nouvelle sortie',
|
||||
@@ -241,6 +241,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Origine',
|
||||
'tours.provenance.source': 'Source', // same-as-en
|
||||
'tours.provenance.importedAt': 'Importé',
|
||||
'tours.provenance.fileImport': 'Import {format}',
|
||||
'tours.provenance.createdAt': 'Créé',
|
||||
'tours.provenance.externalUpdated': 'Modifié sur wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -805,7 +806,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Données cartographiques, pas un bulletin : rien sur l’ouverture, le damage, la neige ou les avalanches.',
|
||||
'tours.map.pisteFullKey': 'Légende complète sur OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Aperçu GPX',
|
||||
'tours.import.previewTitle': "Aperçu de l'import",
|
||||
'tours.import.previewHint': 'Rien n’est encore enregistré. Vérifiez le tracé, puis importez.',
|
||||
'tours.import.previewFiles': '{count} fichiers',
|
||||
'tours.import.previewFiles.one': '{count} fichier',
|
||||
@@ -814,6 +815,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} points', // same-as-en
|
||||
'tours.import.previewPoints.one': '{count} point', // same-as-en
|
||||
'tours.import.previewNoHeights': 'Aucune altitude dans le fichier',
|
||||
'tours.import.previewLeftOut': 'Non importé',
|
||||
'tours.import.previewLeftOutHint':
|
||||
"Les sorties ne sont faites que de lignes. Les repères, zones, superpositions et liens vers d'autres fichiers restent à l'écart, et rien n'est chargé depuis ailleurs.",
|
||||
'tours.import.leftOutPoints': '{count} repères',
|
||||
'tours.import.leftOutPoints.one': '{count} repère',
|
||||
'tours.import.leftOutAreas': '{count} zones',
|
||||
'tours.import.leftOutAreas.one': '{count} zone',
|
||||
'tours.import.leftOutOther': '{count} autres éléments',
|
||||
'tours.import.leftOutOther.one': '{count} autre élément',
|
||||
'tours.import.unreadableIn': 'Impossible de lire {name} : le fichier est endommagé ou n’est pas un GPX, KML ou KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Aucun fichier KML trouvé dans {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} est trop volumineux une fois décompressé',
|
||||
'tours.import.previewDuplicate': 'Déjà dans le voyage',
|
||||
'tours.import.previewAllDuplicates': 'Toutes les traces sont déjà dans le voyage : il n’y a rien à importer.',
|
||||
'tours.import.previewAs': 'Importer comme',
|
||||
|
||||
+24
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Προβολή προσθήκης',
|
||||
'tours.title': 'Εκδρομές',
|
||||
'tours.empty.title': 'Δεν υπάρχουν εκδρομές ακόμη',
|
||||
'tours.empty.body': 'Σχεδιάστε μια εκδρομή ή εισαγάγετε ένα αρχείο GPX για να ξεκινήσετε.',
|
||||
'tours.empty.body': 'Σχεδιάστε μια εκδρομή ή εισαγάγετε ένα αρχείο GPX ή KML για να ξεκινήσετε.',
|
||||
'tours.addToDay': 'Προσθήκη σε ημέρα',
|
||||
'tours.addToDay.comingSoon': 'Αντιστοιχίστε εκδρομές σε ημέρες του ταξιδιού από τη λίστα εκδρομών.',
|
||||
'tours.addToDay.pickDay': 'Επιλέξτε ημέρα',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'χρήση με προσοχή',
|
||||
'tours.caution.tooltip': 'Ελλιπή υψομετρικά δεδομένα ή μετρήσεις – εισαγωγή με επιφύλαξη',
|
||||
'tours.import.caution': 'Εισαγωγή με επιφύλαξη – ελλιπή υψομετρικά δεδομένα ή μετρήσεις',
|
||||
'tours.import.success': 'Εισήχθησαν {count} εκδρομές από GPX',
|
||||
'tours.import.success.one': 'Εισήχθη {count} εκδρομή από GPX',
|
||||
'tours.import.success': 'Εισήχθησαν {count} εκδρομές',
|
||||
'tours.import.success.one': 'Εισήχθη {count} εκδρομή',
|
||||
'tours.import.noTrackIn': 'Δεν βρέθηκε ίχνος ή διαδρομή στο {name}',
|
||||
'tours.import.errorIn': 'Δεν ήταν δυνατή η εισαγωγή του {name}',
|
||||
'tours.import.button': 'Εισαγωγή GPX',
|
||||
'tours.import.hint': 'Εισαγωγή αρχείων GPX ως εκδρομών αυτού του ταξιδιού',
|
||||
'tours.import.unsupported': 'Μόνο αρχεία GPX μπορούν να εισαχθούν ως εκδρομές.',
|
||||
'tours.import.button': 'Εισαγωγή GPX ή KML',
|
||||
'tours.import.hint': 'Εισαγωγή αρχείων GPX, KML ή KMZ ως εκδρομών αυτού του ταξιδιού',
|
||||
'tours.import.unsupported': 'Μόνο αρχεία GPX, KML και KMZ μπορούν να εισαχθούν ως εκδρομές.',
|
||||
'tours.import.skipped': '{count} ίχνη υπήρχαν ήδη σε αυτό το ταξίδι και παραλείφθηκαν',
|
||||
'tours.import.skipped.one': '{count} ίχνος υπήρχε ήδη σε αυτό το ταξίδι και παραλείφθηκε',
|
||||
'tours.import.allSkipped': 'Όλο το περιεχόμενο του αρχείου υπάρχει ήδη σε αυτό το ταξίδι.',
|
||||
'tours.import.drop': 'Αφήστε εδώ αρχεία GPX για να τα εισαγάγετε',
|
||||
'tours.import.drop': 'Αφήστε εδώ αρχεία GPX, KML ή KMZ για να τα εισαγάγετε',
|
||||
'tours.import.dropHint': 'Κάθε ίχνος ή διαδρομή ενός αρχείου γίνεται εκδρομή αυτού του ταξιδιού.',
|
||||
'tours.import.dropNoPermission': 'Δεν μπορείτε να προσθέσετε εκδρομές σε αυτό το ταξίδι.',
|
||||
'tours.import.importing': 'Εισαγωγή GPX…',
|
||||
'tours.import.importing': 'Εισαγωγή…',
|
||||
'tours.days.label': 'Ημέρες ταξιδιού',
|
||||
'tours.days.none': 'Δεν έχει προστεθεί ακόμη σε καμία ημέρα του ταξιδιού.',
|
||||
'tours.days.short': 'Ημέρες {days}',
|
||||
@@ -100,7 +100,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Σχεδιασμός νέας εκδρομής',
|
||||
'tours.planner.addTour': 'Προσθήκη διαδρομής',
|
||||
'tours.planner.addTourHint':
|
||||
'Σχεδίασε νέα διαδρομή, εισήγαγε αρχεία GPX ή μια βόλτα από το Google Maps, ή πάρε μια διαδρομή από άλλο ταξίδι',
|
||||
'Σχεδίασε νέα διαδρομή, εισήγαγε αρχεία GPX ή KML ή μια βόλτα από το Google Maps, ή πάρε μια διαδρομή από άλλο ταξίδι',
|
||||
'tours.planner.neutralTitle': 'Σχεδιασμός εκδρομής',
|
||||
'tours.planner.neutralBody': 'Ξεκινήστε μια νέα διαδρομή και κάντε κλικ στον χάρτη για να ορίσετε τα σημεία της.',
|
||||
'tours.planner.newTitle': 'Νέα εκδρομή',
|
||||
@@ -242,6 +242,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Προέλευση',
|
||||
'tours.provenance.source': 'Πηγή',
|
||||
'tours.provenance.importedAt': 'Εισήχθη',
|
||||
'tours.provenance.fileImport': 'Εισαγωγή {format}',
|
||||
'tours.provenance.createdAt': 'Δημιουργήθηκε',
|
||||
'tours.provenance.externalUpdated': 'Άλλαξε στο wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -809,7 +810,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Δεδομένα χάρτη, όχι ενημέρωση: δεν λένε τι είναι ανοιχτό ή περιποιημένο, ούτε για χιόνι ή χιονοστιβάδες.',
|
||||
'tours.map.pisteFullKey': 'Πλήρες υπόμνημα στο OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Προεπισκόπηση GPX',
|
||||
'tours.import.previewTitle': 'Προεπισκόπηση εισαγωγής',
|
||||
'tours.import.previewHint': 'Δεν έχει αποθηκευτεί τίποτα ακόμη. Ελέγξτε τη διαδρομή και μετά κάντε εισαγωγή.',
|
||||
'tours.import.previewFiles': '{count} αρχεία',
|
||||
'tours.import.previewFiles.one': '{count} αρχείο',
|
||||
@@ -818,6 +819,19 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} σημεία',
|
||||
'tours.import.previewPoints.one': '{count} σημείο',
|
||||
'tours.import.previewNoHeights': 'Χωρίς υψόμετρα στο αρχείο',
|
||||
'tours.import.previewLeftOut': 'Δεν εισάγεται',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Οι εκδρομές φτιάχνονται μόνο από γραμμές. Πινέζες, περιοχές, επικαλύψεις και σύνδεσμοι σε άλλα αρχεία μένουν εκτός, και τίποτα δεν φορτώνεται από αλλού.',
|
||||
'tours.import.leftOutPoints': '{count} πινέζες',
|
||||
'tours.import.leftOutPoints.one': '{count} πινέζα',
|
||||
'tours.import.leftOutAreas': '{count} περιοχές',
|
||||
'tours.import.leftOutAreas.one': '{count} περιοχή',
|
||||
'tours.import.leftOutOther': '{count} άλλα στοιχεία',
|
||||
'tours.import.leftOutOther.one': '{count} άλλο στοιχείο',
|
||||
'tours.import.unreadableIn':
|
||||
'Δεν ήταν δυνατή η ανάγνωση του {name}: το αρχείο είναι κατεστραμμένο ή δεν είναι GPX, KML ή KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Δεν βρέθηκε αρχείο KML στο {name}',
|
||||
'tours.import.archiveTooLargeIn': 'Το {name} είναι πολύ μεγάλο μετά την αποσυμπίεση',
|
||||
'tours.import.previewDuplicate': 'Υπάρχει ήδη στο ταξίδι',
|
||||
'tours.import.previewAllDuplicates': 'Όλα τα ίχνη υπάρχουν ήδη στο ταξίδι, δεν υπάρχει τίποτα για εισαγωγή.',
|
||||
'tours.import.previewAs': 'Εισαγωγή ως',
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Hozzáadási nézet',
|
||||
'tours.title': 'Túrák',
|
||||
'tours.empty.title': 'Még nincsenek túrák',
|
||||
'tours.empty.body': 'A kezdéshez tervezz egy túrát, vagy importálj egy GPX-fájlt.',
|
||||
'tours.empty.body': 'A kezdéshez tervezz egy túrát, vagy importálj egy GPX- vagy KML-fájlt.',
|
||||
'tours.addToDay': 'Hozzáadás egy naphoz',
|
||||
'tours.addToDay.comingSoon': 'A túralistából rendelj túrákat az utazás valamelyik napjához.',
|
||||
'tours.addToDay.pickDay': 'Válassz napot',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'óvatosan használd',
|
||||
'tours.caution.tooltip': 'Hiányos magassági adatok vagy mutatók – fenntartással importálva',
|
||||
'tours.import.caution': 'Fenntartással importálva – hiányos magassági adatok vagy mutatók',
|
||||
'tours.import.success': '{count} túra importálva GPX-ből',
|
||||
'tours.import.success.one': '{count} túra importálva GPX-ből',
|
||||
'tours.import.success': '{count} túra importálva',
|
||||
'tours.import.success.one': '{count} túra importálva',
|
||||
'tours.import.noTrackIn': 'Nem található nyomvonal vagy útvonal ebben: {name}',
|
||||
'tours.import.errorIn': 'Nem sikerült importálni: {name}',
|
||||
'tours.import.button': 'GPX importálása',
|
||||
'tours.import.hint': 'GPX-fájlok importálása az utazás túráiként',
|
||||
'tours.import.unsupported': 'Túraként csak GPX-fájlok importálhatók.',
|
||||
'tours.import.button': 'GPX vagy KML importálása',
|
||||
'tours.import.hint': 'GPX-, KML- vagy KMZ-fájlok importálása az utazás túráiként',
|
||||
'tours.import.unsupported': 'Túraként csak GPX-, KML- és KMZ-fájlok importálhatók.',
|
||||
'tours.import.skipped': '{count} nyomvonal már szerepelt az utazásban, ezért kihagytuk',
|
||||
'tours.import.skipped.one': '{count} nyomvonal már szerepelt az utazásban, ezért kihagytuk',
|
||||
'tours.import.allSkipped': 'A fájl teljes tartalma már szerepel ebben az utazásban.',
|
||||
'tours.import.drop': 'Húzd ide a GPX-fájlokat az importáláshoz',
|
||||
'tours.import.drop': 'Húzd ide a GPX-, KML- vagy KMZ-fájlokat az importáláshoz',
|
||||
'tours.import.dropHint': 'A fájl minden nyomvonalából vagy útvonalából az utazás egy túrája lesz.',
|
||||
'tours.import.dropNoPermission': 'Ehhez az utazáshoz nem adhatsz hozzá túrákat.',
|
||||
'tours.import.importing': 'GPX importálása…',
|
||||
'tours.import.importing': 'Importálás…',
|
||||
'tours.days.label': 'Utazási napok',
|
||||
'tours.days.none': 'Még nincs az utazás egyik napjához sem rendelve.',
|
||||
'tours.days.short': 'Napok: {days}',
|
||||
@@ -97,7 +97,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Új túra tervezése',
|
||||
'tours.planner.addTour': 'Túra hozzáadása',
|
||||
'tours.planner.addTourHint':
|
||||
'Új túra tervezése, GPX-fájlok vagy egy Google Maps-séta importálása, vagy túra átvétele egy másik utazásból',
|
||||
'Új túra tervezése, GPX- vagy KML-fájlok vagy egy Google Maps-séta importálása, vagy túra átvétele egy másik utazásból',
|
||||
'tours.planner.neutralTitle': 'Túra tervezése',
|
||||
'tours.planner.neutralBody': 'Indíts új túrát, és kattints a térképre a pontjai elhelyezéséhez.',
|
||||
'tours.planner.newTitle': 'Új túra',
|
||||
@@ -238,6 +238,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Eredet',
|
||||
'tours.provenance.source': 'Forrás',
|
||||
'tours.provenance.importedAt': 'Importálva',
|
||||
'tours.provenance.fileImport': '{format}-import',
|
||||
'tours.provenance.createdAt': 'Létrehozva',
|
||||
'tours.provenance.externalUpdated': 'Módosítva a wandereren',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -796,7 +797,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Térképadat, nem helyzetjelentés: semmit sem mond arról, mi van nyitva vagy előkészítve, a hóról vagy a lavinákról.',
|
||||
'tours.map.pisteFullKey': 'Teljes jelmagyarázat az OpenSnowMapen',
|
||||
'tours.import.previewTitle': 'GPX-előnézet',
|
||||
'tours.import.previewTitle': 'Importálás előnézete',
|
||||
'tours.import.previewHint': 'Még semmi sincs mentve. Ellenőrizd a nyomvonalat, aztán importáld.',
|
||||
'tours.import.previewFiles': '{count} fájl',
|
||||
'tours.import.previewFiles.one': '{count} fájl',
|
||||
@@ -805,6 +806,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} pont',
|
||||
'tours.import.previewPoints.one': '{count} pont',
|
||||
'tours.import.previewNoHeights': 'Nincsenek magasságok a fájlban',
|
||||
'tours.import.previewLeftOut': 'Nem kerül importálásra',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'A túrák csak vonalakból állnak. A tűk, területek, rétegek és más fájlokra mutató hivatkozások kimaradnak, és semmi sem töltődik be máshonnan.',
|
||||
'tours.import.leftOutPoints': '{count} tű',
|
||||
'tours.import.leftOutPoints.one': '{count} tű',
|
||||
'tours.import.leftOutAreas': '{count} terület',
|
||||
'tours.import.leftOutAreas.one': '{count} terület',
|
||||
'tours.import.leftOutOther': '{count} egyéb elem',
|
||||
'tours.import.leftOutOther.one': '{count} egyéb elem',
|
||||
'tours.import.unreadableIn': 'A(z) {name} nem olvasható: a fájl sérült, vagy nem GPX, KML vagy KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'A(z) {name} nem tartalmaz KML-fájlt',
|
||||
'tours.import.archiveTooLargeIn': 'A(z) {name} kicsomagolva túl nagy',
|
||||
'tours.import.previewDuplicate': 'Már az utazásban van',
|
||||
'tours.import.previewAllDuplicates': 'Minden nyomvonal már az utazásban van, nincs mit importálni.',
|
||||
'tours.import.previewAs': 'Importálás mint',
|
||||
|
||||
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Tampilan penambahan',
|
||||
'tours.title': 'Tur',
|
||||
'tours.empty.title': 'Belum ada tur',
|
||||
'tours.empty.body': 'Rencanakan tur atau impor berkas GPX untuk memulai.',
|
||||
'tours.empty.body': 'Rencanakan tur atau impor berkas GPX atau KML untuk memulai.',
|
||||
'tours.addToDay': 'Tambahkan ke hari',
|
||||
'tours.addToDay.comingSoon': 'Tetapkan tur ke hari perjalanan dari daftar tur.',
|
||||
'tours.addToDay.pickDay': 'Pilih hari',
|
||||
@@ -20,18 +20,18 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'gunakan dengan hati-hati',
|
||||
'tours.caution.tooltip': 'Data elevasi atau metrik belum lengkap; diimpor dengan peringatan',
|
||||
'tours.import.caution': 'Diimpor dengan peringatan; data elevasi atau metrik belum lengkap',
|
||||
'tours.import.success': '{count} tur diimpor dari GPX',
|
||||
'tours.import.success': '{count} tur diimpor',
|
||||
'tours.import.noTrackIn': 'Tidak ditemukan jejak atau rute dalam {name}',
|
||||
'tours.import.errorIn': '{name} tidak dapat diimpor',
|
||||
'tours.import.button': 'Impor GPX',
|
||||
'tours.import.hint': 'Impor berkas GPX sebagai tur perjalanan ini',
|
||||
'tours.import.unsupported': 'Hanya berkas GPX yang dapat diimpor sebagai tur.',
|
||||
'tours.import.button': 'Impor GPX atau KML',
|
||||
'tours.import.hint': 'Impor berkas GPX, KML, atau KMZ sebagai tur perjalanan ini',
|
||||
'tours.import.unsupported': 'Hanya berkas GPX, KML, dan KMZ yang dapat diimpor sebagai tur.',
|
||||
'tours.import.skipped': '{count} jejak sudah ada dalam perjalanan ini dan dilewati',
|
||||
'tours.import.allSkipped': 'Semua isi berkas itu sudah ada dalam perjalanan ini.',
|
||||
'tours.import.drop': 'Lepaskan berkas GPX untuk mengimpornya',
|
||||
'tours.import.drop': 'Lepaskan berkas GPX, KML, atau KMZ untuk mengimpornya',
|
||||
'tours.import.dropHint': 'Setiap jejak atau rute dalam berkas menjadi tur perjalanan ini.',
|
||||
'tours.import.dropNoPermission': 'Anda tidak dapat menambahkan tur ke perjalanan ini.',
|
||||
'tours.import.importing': 'Mengimpor GPX…',
|
||||
'tours.import.importing': 'Mengimpor…',
|
||||
'tours.days.label': 'Hari perjalanan',
|
||||
'tours.days.none': 'Belum ada di hari mana pun dalam perjalanan.',
|
||||
'tours.days.short': 'Hari {days}',
|
||||
@@ -94,7 +94,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Rencanakan tur baru',
|
||||
'tours.planner.addTour': 'Tambah tur',
|
||||
'tours.planner.addTourHint':
|
||||
'Rencanakan tur baru, impor file GPX atau rute jalan kaki dari Google Maps, atau ambil tur dari perjalanan lain',
|
||||
'Rencanakan tur baru, impor file GPX atau KML atau rute jalan kaki dari Google Maps, atau ambil tur dari perjalanan lain',
|
||||
'tours.planner.neutralTitle': 'Rencanakan tur',
|
||||
'tours.planner.neutralBody': 'Mulai tur baru dan klik peta untuk menempatkan titik-titiknya.',
|
||||
'tours.planner.newTitle': 'Tur baru',
|
||||
@@ -232,6 +232,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Asal',
|
||||
'tours.provenance.source': 'Sumber',
|
||||
'tours.provenance.importedAt': 'Diimpor',
|
||||
'tours.provenance.fileImport': 'Impor {format}',
|
||||
'tours.provenance.createdAt': 'Dibuat',
|
||||
'tours.provenance.externalUpdated': 'Diubah di wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -787,12 +788,21 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Data peta, bukan laporan terkini: tidak menyebut apa yang buka atau sudah dipadatkan, salju, atau longsoran.',
|
||||
'tours.map.pisteFullKey': 'Keterangan lengkap di OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Pratinjau GPX',
|
||||
'tours.import.previewTitle': 'Pratinjau impor',
|
||||
'tours.import.previewHint': 'Belum ada yang disimpan. Periksa jalurnya, lalu impor.',
|
||||
'tours.import.previewFiles': '{count} berkas',
|
||||
'tours.import.previewTracks': '{count} jejak',
|
||||
'tours.import.previewPoints': '{count} titik',
|
||||
'tours.import.previewNoHeights': 'Tidak ada ketinggian di berkas',
|
||||
'tours.import.previewLeftOut': 'Tidak diimpor',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Tur hanya dibuat dari garis. Penanda, wilayah, lapisan, dan tautan ke berkas lain tidak ikut, dan tidak ada yang dimuat dari tempat lain.',
|
||||
'tours.import.leftOutPoints': '{count} penanda',
|
||||
'tours.import.leftOutAreas': '{count} wilayah',
|
||||
'tours.import.leftOutOther': '{count} item lain',
|
||||
'tours.import.unreadableIn': '{name} tidak dapat dibaca: berkas rusak atau bukan GPX, KML, atau KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Tidak ada berkas KML di {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} terlalu besar setelah diekstrak',
|
||||
'tours.import.previewDuplicate': 'Sudah ada di perjalanan',
|
||||
'tours.import.previewAllDuplicates': 'Semua jejak sudah ada di perjalanan, tidak ada yang perlu diimpor.',
|
||||
'tours.import.previewAs': 'Impor sebagai',
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Aggiungi vista',
|
||||
'tours.title': 'Escursioni',
|
||||
'tours.empty.title': 'Nessuna escursione ancora',
|
||||
'tours.empty.body': "Pianifica un'escursione o importa un file GPX per iniziare.",
|
||||
'tours.empty.body': "Pianifica un'escursione o importa un file GPX o KML per iniziare.",
|
||||
'tours.addToDay': 'Aggiungi a un giorno',
|
||||
'tours.addToDay.comingSoon': 'Assegna le escursioni a un giorno del viaggio dalla lista delle escursioni.',
|
||||
'tours.addToDay.pickDay': 'Scegli un giorno',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'usare con cautela',
|
||||
'tours.caution.tooltip': 'Quote o metriche incomplete: importata con riserva',
|
||||
'tours.import.caution': 'Importata con riserva: quote o metriche incomplete',
|
||||
'tours.import.success': '{count} escursioni importate da GPX',
|
||||
'tours.import.success.one': '{count} escursione importata da GPX',
|
||||
'tours.import.success': '{count} escursioni importate',
|
||||
'tours.import.success.one': '{count} escursione importata',
|
||||
'tours.import.noTrackIn': 'Nessuna traccia o percorso trovato in {name}',
|
||||
'tours.import.errorIn': 'Impossibile importare {name}',
|
||||
'tours.import.button': 'Importa GPX',
|
||||
'tours.import.hint': 'Importa file GPX come escursioni di questo viaggio',
|
||||
'tours.import.unsupported': 'Solo i file GPX possono essere importati come escursioni.',
|
||||
'tours.import.button': 'Importa GPX o KML',
|
||||
'tours.import.hint': 'Importa file GPX, KML o KMZ come escursioni di questo viaggio',
|
||||
'tours.import.unsupported': 'Solo i file GPX, KML e KMZ possono essere importati come escursioni.',
|
||||
'tours.import.skipped': '{count} tracce erano già in questo viaggio e sono state saltate',
|
||||
'tours.import.skipped.one': '{count} traccia era già in questo viaggio ed è stata saltata',
|
||||
'tours.import.allSkipped': 'Tutto il contenuto di quel file è già in questo viaggio.',
|
||||
'tours.import.drop': 'Rilascia i file GPX per importarli',
|
||||
'tours.import.drop': 'Rilascia i file GPX, KML o KMZ per importarli',
|
||||
'tours.import.dropHint': "Ogni traccia o percorso di un file diventa un'escursione di questo viaggio.",
|
||||
'tours.import.dropNoPermission': 'Non puoi aggiungere escursioni a questo viaggio.',
|
||||
'tours.import.importing': 'Importazione GPX…',
|
||||
'tours.import.importing': 'Importazione…',
|
||||
'tours.days.label': 'Giorni del viaggio',
|
||||
'tours.days.none': 'Non ancora in nessun giorno del viaggio.',
|
||||
'tours.days.short': 'Giorni {days}',
|
||||
@@ -98,7 +98,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Pianifica nuova escursione',
|
||||
'tours.planner.addTour': 'Aggiungi giro',
|
||||
'tours.planner.addTourHint':
|
||||
'Pianifica un nuovo giro, importa file GPX o una passeggiata da Google Maps, o riprendi un giro da un altro viaggio',
|
||||
'Pianifica un nuovo giro, importa file GPX o KML o una passeggiata da Google Maps, o riprendi un giro da un altro viaggio',
|
||||
'tours.planner.neutralTitle': "Pianifica un'escursione",
|
||||
'tours.planner.neutralBody': 'Inizia un nuovo percorso e fai clic sulla mappa per posizionarne i punti.',
|
||||
'tours.planner.newTitle': 'Nuova escursione',
|
||||
@@ -237,6 +237,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Origine',
|
||||
'tours.provenance.source': 'Fonte',
|
||||
'tours.provenance.importedAt': 'Importato',
|
||||
'tours.provenance.fileImport': 'Importazione {format}',
|
||||
'tours.provenance.createdAt': 'Creato',
|
||||
'tours.provenance.externalUpdated': 'Modificato su wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -797,7 +798,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteLift': 'Impianti di risalita',
|
||||
'tours.map.pisteStatic': 'Dati della mappa, non un bollettino: nulla su apertura, battitura, neve o valanghe.',
|
||||
'tours.map.pisteFullKey': 'Legenda completa su OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Anteprima GPX',
|
||||
'tours.import.previewTitle': "Anteprima dell'importazione",
|
||||
'tours.import.previewHint': 'Non è ancora stato salvato nulla. Controlla il percorso, poi importa.',
|
||||
'tours.import.previewFiles': '{count} file',
|
||||
'tours.import.previewFiles.one': '{count} file', // same-as-en
|
||||
@@ -806,6 +807,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} punti',
|
||||
'tours.import.previewPoints.one': '{count} punto',
|
||||
'tours.import.previewNoHeights': 'Nessuna quota nel file',
|
||||
'tours.import.previewLeftOut': 'Non importato',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Le escursioni sono fatte solo di linee. Segnaposti, superfici, sovrapposizioni e collegamenti ad altri file restano fuori, e nulla viene caricato da altrove.',
|
||||
'tours.import.leftOutPoints': '{count} segnaposti',
|
||||
'tours.import.leftOutPoints.one': '{count} segnaposto',
|
||||
'tours.import.leftOutAreas': '{count} superfici',
|
||||
'tours.import.leftOutAreas.one': '{count} superficie',
|
||||
'tours.import.leftOutOther': '{count} altri elementi',
|
||||
'tours.import.leftOutOther.one': '{count} altro elemento',
|
||||
'tours.import.unreadableIn': 'Impossibile leggere {name}: il file è danneggiato o non è GPX, KML o KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Nessun file KML trovato in {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} è troppo grande una volta decompresso',
|
||||
'tours.import.previewDuplicate': 'Già nel viaggio',
|
||||
'tours.import.previewAllDuplicates': 'Tutte le tracce sono già nel viaggio, non c’è nulla da importare.',
|
||||
'tours.import.previewAs': 'Importa come',
|
||||
|
||||
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': '追加する項目',
|
||||
'tours.title': 'ツアー',
|
||||
'tours.empty.title': 'ツアーはまだありません',
|
||||
'tours.empty.body': 'ツアーを計画するか、GPXファイルをインポートしてください。',
|
||||
'tours.empty.body': 'ツアーを計画するか、GPXまたはKMLファイルをインポートしてください。',
|
||||
'tours.addToDay': '日程に追加',
|
||||
'tours.addToDay.comingSoon': 'ツアー一覧から旅行の日程に割り当てます。',
|
||||
'tours.addToDay.pickDay': '日程を選択',
|
||||
@@ -20,18 +20,18 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': '利用時は注意',
|
||||
'tours.caution.tooltip': '標高・各種データが不完全です。注意が必要な状態でインポートされました。',
|
||||
'tours.import.caution': '標高・各種データが不完全なため、注意が必要な状態でインポートしました。',
|
||||
'tours.import.success': 'GPXから{count}件のツアーをインポートしました',
|
||||
'tours.import.success': '{count}件のツアーをインポートしました',
|
||||
'tours.import.noTrackIn': '{name}にトラックやルートが見つかりません',
|
||||
'tours.import.errorIn': '{name}をインポートできませんでした',
|
||||
'tours.import.button': 'GPXをインポート',
|
||||
'tours.import.hint': 'GPXファイルをこの旅行のツアーとしてインポートします',
|
||||
'tours.import.unsupported': 'ツアーとしてインポートできるのはGPXファイルのみです。',
|
||||
'tours.import.button': 'GPXまたはKMLをインポート',
|
||||
'tours.import.hint': 'GPX、KML、KMZファイルをこの旅行のツアーとしてインポートします',
|
||||
'tours.import.unsupported': 'ツアーとしてインポートできるのはGPX、KML、KMZファイルのみです。',
|
||||
'tours.import.skipped': '{count}件のトラックはすでにこの旅行にあるため、スキップしました',
|
||||
'tours.import.allSkipped': 'このファイルの内容はすべて、すでにこの旅行にあります。',
|
||||
'tours.import.drop': 'GPXファイルをドロップしてインポート',
|
||||
'tours.import.drop': 'GPX、KML、KMZファイルをドロップしてインポート',
|
||||
'tours.import.dropHint': 'ファイル内の各トラックやルートが、それぞれこの旅行のツアーになります。',
|
||||
'tours.import.dropNoPermission': 'この旅行にツアーを追加する権限がありません。',
|
||||
'tours.import.importing': 'GPXをインポート中…',
|
||||
'tours.import.importing': 'インポート中…',
|
||||
'tours.days.label': '旅行の日程',
|
||||
'tours.days.none': 'まだ旅行のどの日にも入っていません。',
|
||||
'tours.days.short': '{days}日目',
|
||||
@@ -93,7 +93,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': '新しいツアーを計画',
|
||||
'tours.planner.addTour': 'ツアーを追加',
|
||||
'tours.planner.addTourHint':
|
||||
'新しいツアーを計画、GPX ファイルや Google Maps の街歩きを読み込み、または別の旅行からツアーを引き継ぎます',
|
||||
'新しいツアーを計画、GPX や KML ファイル、Google Maps の街歩きを読み込み、または別の旅行からツアーを引き継ぎます',
|
||||
'tours.planner.neutralTitle': 'ツアーを計画',
|
||||
'tours.planner.neutralBody': '新しいツアーを始めて、地図をクリックして経由地点を置きます。',
|
||||
'tours.planner.newTitle': '新しいツアー',
|
||||
@@ -226,6 +226,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': '出どころ',
|
||||
'tours.provenance.source': 'ソース',
|
||||
'tours.provenance.importedAt': 'インポート日',
|
||||
'tours.provenance.fileImport': '{format}インポート',
|
||||
'tours.provenance.createdAt': '作成日',
|
||||
'tours.provenance.externalUpdated': 'wanderer での変更',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -767,12 +768,21 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'地図データであり現況情報ではありません。営業中か、圧雪済みか、雪や雪崩については何も示しません。',
|
||||
'tours.map.pisteFullKey': 'OpenSnowMap の完全な凡例',
|
||||
'tours.import.previewTitle': 'GPX プレビュー',
|
||||
'tours.import.previewTitle': 'インポートのプレビュー',
|
||||
'tours.import.previewHint': 'まだ何も保存されていません。コースを確認してから取り込んでください。',
|
||||
'tours.import.previewFiles': '{count} 件のファイル',
|
||||
'tours.import.previewTracks': '{count} 本のトラック',
|
||||
'tours.import.previewPoints': '{count} 個のポイント',
|
||||
'tours.import.previewNoHeights': 'ファイルに標高がありません',
|
||||
'tours.import.previewLeftOut': 'インポートされません',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'ツアーは線だけから作られます。ピン、エリア、オーバーレイ、他のファイルへのリンクは対象外で、外部から何も読み込みません。',
|
||||
'tours.import.leftOutPoints': 'ピン {count} 個',
|
||||
'tours.import.leftOutAreas': 'エリア {count} 件',
|
||||
'tours.import.leftOutOther': 'その他 {count} 件',
|
||||
'tours.import.unreadableIn': '{name}を読み込めませんでした:ファイルが破損しているか、GPX、KML、KMZではありません',
|
||||
'tours.import.archiveNoKmlIn': '{name}にKMLファイルが見つかりません',
|
||||
'tours.import.archiveTooLargeIn': '{name}は展開すると大きすぎます',
|
||||
'tours.import.previewDuplicate': 'すでに旅行にあります',
|
||||
'tours.import.previewAllDuplicates': 'すべてのトラックがすでに旅行にあるため、取り込むものはありません。',
|
||||
'tours.import.previewAs': '取り込む種類',
|
||||
|
||||
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': '추가 항목 보기',
|
||||
'tours.title': '투어',
|
||||
'tours.empty.title': '아직 투어가 없습니다',
|
||||
'tours.empty.body': '투어를 계획하거나 GPX 파일을 가져와 시작하세요.',
|
||||
'tours.empty.body': '투어를 계획하거나 GPX 또는 KML 파일을 가져와 시작하세요.',
|
||||
'tours.addToDay': '일정에 추가',
|
||||
'tours.addToDay.comingSoon': '투어 목록에서 여행의 특정 날짜에 투어를 배정하세요.',
|
||||
'tours.addToDay.pickDay': '날짜 선택',
|
||||
@@ -20,18 +20,18 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': '이용 시 주의',
|
||||
'tours.caution.tooltip': '고도 또는 측정 데이터가 불완전하여 주의가 필요한 상태로 가져왔습니다',
|
||||
'tours.import.caution': '고도 또는 측정 데이터가 불완전하여 주의가 필요한 상태로 가져왔습니다',
|
||||
'tours.import.success': 'GPX에서 투어 {count}개를 가져왔습니다',
|
||||
'tours.import.success': '투어 {count}개를 가져왔습니다',
|
||||
'tours.import.noTrackIn': '{name}에서 트랙이나 경로를 찾을 수 없습니다',
|
||||
'tours.import.errorIn': '{name}을(를) 가져오지 못했습니다',
|
||||
'tours.import.button': 'GPX 가져오기',
|
||||
'tours.import.hint': 'GPX 파일을 이 여행의 투어로 가져옵니다',
|
||||
'tours.import.unsupported': '투어로 가져올 수 있는 파일은 GPX뿐입니다.',
|
||||
'tours.import.button': 'GPX 또는 KML 가져오기',
|
||||
'tours.import.hint': 'GPX, KML 또는 KMZ 파일을 이 여행의 투어로 가져옵니다',
|
||||
'tours.import.unsupported': '투어로 가져올 수 있는 파일은 GPX, KML, KMZ뿐입니다.',
|
||||
'tours.import.skipped': '트랙 {count}개는 이미 이 여행에 있어 건너뛰었습니다',
|
||||
'tours.import.allSkipped': '이 파일의 내용은 모두 이미 이 여행에 있습니다.',
|
||||
'tours.import.drop': 'GPX 파일을 놓아 가져오기',
|
||||
'tours.import.drop': 'GPX, KML 또는 KMZ 파일을 놓아 가져오기',
|
||||
'tours.import.dropHint': '파일의 각 트랙이나 경로가 이 여행의 투어가 됩니다.',
|
||||
'tours.import.dropNoPermission': '이 여행에 투어를 추가할 수 없습니다.',
|
||||
'tours.import.importing': 'GPX 가져오는 중…',
|
||||
'tours.import.importing': '가져오는 중…',
|
||||
'tours.days.label': '여행 날짜',
|
||||
'tours.days.none': '아직 여행의 어느 날짜에도 배정되지 않았습니다.',
|
||||
'tours.days.short': '{days}일차',
|
||||
@@ -90,7 +90,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': '새 투어 계획',
|
||||
'tours.planner.addTour': '투어 추가',
|
||||
'tours.planner.addTourHint':
|
||||
'새 투어를 계획하거나 GPX 파일이나 Google Maps 산책을 가져오거나 다른 여행의 투어를 가져옵니다',
|
||||
'새 투어를 계획하거나 GPX 또는 KML 파일이나 Google Maps 산책을 가져오거나 다른 여행의 투어를 가져옵니다',
|
||||
'tours.planner.neutralTitle': '투어 계획',
|
||||
'tours.planner.neutralBody': '새 투어를 시작하고 지도를 클릭해 경유 지점을 놓으세요.',
|
||||
'tours.planner.newTitle': '새 투어',
|
||||
@@ -225,6 +225,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': '출처',
|
||||
'tours.provenance.source': '소스',
|
||||
'tours.provenance.importedAt': '가져온 날짜',
|
||||
'tours.provenance.fileImport': '{format} 가져오기',
|
||||
'tours.provenance.createdAt': '만든 날짜',
|
||||
'tours.provenance.externalUpdated': 'wanderer에서 변경됨',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -765,12 +766,21 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'지도 데이터일 뿐 실시간 정보가 아닙니다. 운영 여부, 정설 상태, 눈이나 눈사태에 대해서는 알려주지 않습니다.',
|
||||
'tours.map.pisteFullKey': 'OpenSnowMap의 전체 범례',
|
||||
'tours.import.previewTitle': 'GPX 미리보기',
|
||||
'tours.import.previewTitle': '가져오기 미리보기',
|
||||
'tours.import.previewHint': '아직 저장된 것이 없습니다. 경로를 확인한 뒤 가져오세요.',
|
||||
'tours.import.previewFiles': '파일 {count}개',
|
||||
'tours.import.previewTracks': '트랙 {count}개',
|
||||
'tours.import.previewPoints': '지점 {count}개',
|
||||
'tours.import.previewNoHeights': '파일에 고도가 없습니다',
|
||||
'tours.import.previewLeftOut': '가져오지 않음',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'투어는 선으로만 만들어집니다. 핀, 영역, 오버레이, 다른 파일로의 링크는 제외되며 다른 곳에서 아무것도 불러오지 않습니다.',
|
||||
'tours.import.leftOutPoints': '핀 {count}개',
|
||||
'tours.import.leftOutAreas': '영역 {count}개',
|
||||
'tours.import.leftOutOther': '기타 항목 {count}개',
|
||||
'tours.import.unreadableIn': '{name}을(를) 읽을 수 없습니다: 파일이 손상되었거나 GPX, KML, KMZ가 아닙니다',
|
||||
'tours.import.archiveNoKmlIn': '{name}에 KML 파일이 없습니다',
|
||||
'tours.import.archiveTooLargeIn': '{name}은(는) 압축을 풀면 너무 큽니다',
|
||||
'tours.import.previewDuplicate': '이미 여행에 있음',
|
||||
'tours.import.previewAllDuplicates': '모든 트랙이 이미 여행에 있어 가져올 것이 없습니다.',
|
||||
'tours.import.previewAs': '가져올 유형',
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Weergave toevoegen',
|
||||
'tours.title': 'Tochten',
|
||||
'tours.empty.title': 'Nog geen tochten',
|
||||
'tours.empty.body': 'Plan een tocht of importeer een GPX-bestand om te beginnen.',
|
||||
'tours.empty.body': 'Plan een tocht of importeer een GPX- of KML-bestand om te beginnen.',
|
||||
'tours.addToDay': 'Aan dag toevoegen',
|
||||
'tours.addToDay.comingSoon': 'Wijs tochten vanuit de tochtenlijst toe aan een reisdag.',
|
||||
'tours.addToDay.pickDay': 'Kies een dag',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'voorzichtig gebruiken',
|
||||
'tours.caution.tooltip': 'Hoogtegegevens of meetwaarden onvolledig: met voorbehoud geïmporteerd',
|
||||
'tours.import.caution': 'Met voorbehoud geïmporteerd: hoogtegegevens of meetwaarden onvolledig',
|
||||
'tours.import.success': '{count} tochten geïmporteerd uit GPX',
|
||||
'tours.import.success.one': '{count} tocht geïmporteerd uit GPX',
|
||||
'tours.import.success': '{count} tochten geïmporteerd',
|
||||
'tours.import.success.one': '{count} tocht geïmporteerd',
|
||||
'tours.import.noTrackIn': 'Geen track of route gevonden in {name}',
|
||||
'tours.import.errorIn': '{name} kon niet worden geïmporteerd',
|
||||
'tours.import.button': 'GPX importeren',
|
||||
'tours.import.hint': 'GPX-bestanden importeren als tochten van deze reis',
|
||||
'tours.import.unsupported': 'Alleen GPX-bestanden kunnen als tochten worden geïmporteerd.',
|
||||
'tours.import.button': 'GPX of KML importeren',
|
||||
'tours.import.hint': 'GPX-, KML- of KMZ-bestanden importeren als tochten van deze reis',
|
||||
'tours.import.unsupported': 'Alleen GPX-, KML- en KMZ-bestanden kunnen als tochten worden geïmporteerd.',
|
||||
'tours.import.skipped': '{count} tracks zaten al in deze reis en zijn overgeslagen',
|
||||
'tours.import.skipped.one': '{count} track zat al in deze reis en is overgeslagen',
|
||||
'tours.import.allSkipped': 'Alles in dat bestand zit al in deze reis.',
|
||||
'tours.import.drop': 'Zet GPX-bestanden hier neer om ze te importeren',
|
||||
'tours.import.drop': 'Zet GPX-, KML- of KMZ-bestanden hier neer om ze te importeren',
|
||||
'tours.import.dropHint': 'Elke track of route in een bestand wordt een tocht van deze reis.',
|
||||
'tours.import.dropNoPermission': 'Je kunt geen tochten aan deze reis toevoegen.',
|
||||
'tours.import.importing': 'GPX importeren…',
|
||||
'tours.import.importing': 'Importeren…',
|
||||
'tours.days.label': 'Reisdagen',
|
||||
'tours.days.none': 'Nog op geen enkele dag van de reis.',
|
||||
'tours.days.short': 'Dagen {days}',
|
||||
@@ -99,7 +99,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Nieuwe tocht plannen',
|
||||
'tours.planner.addTour': 'Tocht toevoegen',
|
||||
'tours.planner.addTourHint':
|
||||
'Een nieuwe tocht plannen, GPX-bestanden of een wandeling uit Google Maps importeren, of een tocht uit een andere reis overnemen',
|
||||
'Een nieuwe tocht plannen, GPX- of KML-bestanden of een wandeling uit Google Maps importeren, of een tocht uit een andere reis overnemen',
|
||||
'tours.planner.neutralTitle': 'Een tocht plannen',
|
||||
'tours.planner.neutralBody': 'Begin een nieuwe tocht en klik op de kaart om de punten te plaatsen.',
|
||||
'tours.planner.newTitle': 'Nieuwe tocht',
|
||||
@@ -237,6 +237,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Herkomst',
|
||||
'tours.provenance.source': 'Bron',
|
||||
'tours.provenance.importedAt': 'Geïmporteerd',
|
||||
'tours.provenance.fileImport': '{format}-import',
|
||||
'tours.provenance.createdAt': 'Aangemaakt',
|
||||
'tours.provenance.externalUpdated': 'Gewijzigd op wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -796,7 +797,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Kaartgegevens, geen actueel bericht: niets over wat open of geprepareerd is, sneeuw of lawines.',
|
||||
'tours.map.pisteFullKey': 'Volledige legenda op OpenSnowMap',
|
||||
'tours.import.previewTitle': 'GPX-voorbeeld',
|
||||
'tours.import.previewTitle': 'Importvoorbeeld',
|
||||
'tours.import.previewHint': 'Er is nog niets opgeslagen. Controleer het verloop en importeer dan.',
|
||||
'tours.import.previewFiles': '{count} bestanden',
|
||||
'tours.import.previewFiles.one': '{count} bestand',
|
||||
@@ -805,6 +806,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} punten',
|
||||
'tours.import.previewPoints.one': '{count} punt',
|
||||
'tours.import.previewNoHeights': 'Geen hoogtes in het bestand',
|
||||
'tours.import.previewLeftOut': 'Niet geïmporteerd',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Tochten bestaan alleen uit lijnen. Spelden, gebieden, overlays en links naar andere bestanden blijven buiten, en er wordt niets van elders geladen.',
|
||||
'tours.import.leftOutPoints': '{count} spelden',
|
||||
'tours.import.leftOutPoints.one': '{count} speld',
|
||||
'tours.import.leftOutAreas': '{count} gebieden',
|
||||
'tours.import.leftOutAreas.one': '{count} gebied',
|
||||
'tours.import.leftOutOther': '{count} andere items',
|
||||
'tours.import.leftOutOther.one': '{count} ander item',
|
||||
'tours.import.unreadableIn': '{name} kon niet worden gelezen: het bestand is beschadigd of geen GPX, KML of KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Geen KML-bestand gevonden in {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} is uitgepakt te groot',
|
||||
'tours.import.previewDuplicate': 'Al in de reis',
|
||||
'tours.import.previewAllDuplicates': 'Alle tracks staan al in de reis, er is niets te importeren.',
|
||||
'tours.import.previewAs': 'Importeren als',
|
||||
|
||||
+32
-12
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Widok dodawania',
|
||||
'tours.title': 'Wycieczki',
|
||||
'tours.empty.title': 'Nie ma jeszcze wycieczek',
|
||||
'tours.empty.body': 'Zaplanuj wycieczkę lub zaimportuj plik GPX, aby rozpocząć.',
|
||||
'tours.empty.body': 'Zaplanuj wycieczkę lub zaimportuj plik GPX albo KML, aby rozpocząć.',
|
||||
'tours.addToDay': 'Dodaj do dnia',
|
||||
'tours.addToDay.comingSoon': 'Przypisz wycieczki do dnia podróży z listy wycieczek.',
|
||||
'tours.addToDay.pickDay': 'Wybierz dzień',
|
||||
@@ -20,24 +20,24 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'korzystaj ostrożnie',
|
||||
'tours.caution.tooltip': 'Niepełne dane wysokościowe lub parametry – import z zastrzeżeniami',
|
||||
'tours.import.caution': 'Import z zastrzeżeniami – niepełne dane wysokościowe lub parametry',
|
||||
'tours.import.success': 'Zaimportowano {count} wycieczki z GPX',
|
||||
'tours.import.success.one': 'Zaimportowano {count} wycieczkę z GPX',
|
||||
'tours.import.success.few': 'Zaimportowano {count} wycieczki z GPX',
|
||||
'tours.import.success.many': 'Zaimportowano {count} wycieczek z GPX',
|
||||
'tours.import.success': 'Zaimportowano {count} wycieczki',
|
||||
'tours.import.success.one': 'Zaimportowano {count} wycieczkę',
|
||||
'tours.import.success.few': 'Zaimportowano {count} wycieczki',
|
||||
'tours.import.success.many': 'Zaimportowano {count} wycieczek',
|
||||
'tours.import.noTrackIn': 'W pliku {name} nie znaleziono śladu ani trasy',
|
||||
'tours.import.errorIn': 'Nie udało się zaimportować pliku {name}',
|
||||
'tours.import.button': 'Importuj GPX',
|
||||
'tours.import.hint': 'Importuj pliki GPX jako wycieczki w tej podróży',
|
||||
'tours.import.unsupported': 'Jako wycieczki można importować tylko pliki GPX.',
|
||||
'tours.import.button': 'Importuj GPX lub KML',
|
||||
'tours.import.hint': 'Importuj pliki GPX, KML lub KMZ jako wycieczki w tej podróży',
|
||||
'tours.import.unsupported': 'Jako wycieczki można importować tylko pliki GPX, KML i KMZ.',
|
||||
'tours.import.skipped': '{count} śladu było już w tej podróży, więc je pominięto',
|
||||
'tours.import.skipped.one': '{count} ślad był już w tej podróży, więc go pominięto',
|
||||
'tours.import.skipped.few': '{count} ślady były już w tej podróży, więc je pominięto',
|
||||
'tours.import.skipped.many': '{count} śladów było już w tej podróży, więc je pominięto',
|
||||
'tours.import.allSkipped': 'Wszystko z tego pliku jest już w tej podróży.',
|
||||
'tours.import.drop': 'Upuść pliki GPX, aby je zaimportować',
|
||||
'tours.import.drop': 'Upuść pliki GPX, KML lub KMZ, aby je zaimportować',
|
||||
'tours.import.dropHint': 'Każdy ślad lub trasa w pliku staje się wycieczką w tej podróży.',
|
||||
'tours.import.dropNoPermission': 'Nie możesz dodawać wycieczek do tej podróży.',
|
||||
'tours.import.importing': 'Importowanie GPX…',
|
||||
'tours.import.importing': 'Importowanie…',
|
||||
'tours.days.label': 'Dni podróży',
|
||||
'tours.days.none': 'Jeszcze nieprzypisana do żadnego dnia podróży.',
|
||||
'tours.days.short': 'Dni {days}',
|
||||
@@ -106,7 +106,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Zaplanuj nową wycieczkę',
|
||||
'tours.planner.addTour': 'Dodaj trasę',
|
||||
'tours.planner.addTourHint':
|
||||
'Zaplanuj nową trasę, zaimportuj pliki GPX lub spacer z Google Maps albo przejmij trasę z innej podróży',
|
||||
'Zaplanuj nową trasę, zaimportuj pliki GPX lub KML albo spacer z Google Maps lub przejmij trasę z innej podróży',
|
||||
'tours.planner.neutralTitle': 'Zaplanuj wycieczkę',
|
||||
'tours.planner.neutralBody': 'Rozpocznij nową trasę i kliknij mapę, aby wyznaczyć jej punkty.',
|
||||
'tours.planner.newTitle': 'Nowa wycieczka',
|
||||
@@ -246,6 +246,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Pochodzenie',
|
||||
'tours.provenance.source': 'Źródło',
|
||||
'tours.provenance.importedAt': 'Zaimportowano',
|
||||
'tours.provenance.fileImport': 'Import {format}',
|
||||
'tours.provenance.createdAt': 'Utworzono',
|
||||
'tours.provenance.externalUpdated': 'Zmieniono w wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -802,7 +803,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Dane mapy, nie komunikat: nic o tym, co jest otwarte lub przygotowane, o śniegu ani lawinach.',
|
||||
'tours.map.pisteFullKey': 'Pełna legenda w OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Podgląd GPX',
|
||||
'tours.import.previewTitle': 'Podgląd importu',
|
||||
'tours.import.previewHint': 'Nic jeszcze nie zapisano. Sprawdź przebieg, a potem zaimportuj.',
|
||||
'tours.import.previewFiles': '{count} pliku',
|
||||
'tours.import.previewFiles.one': '{count} plik',
|
||||
@@ -817,6 +818,25 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints.few': '{count} punkty',
|
||||
'tours.import.previewPoints.many': '{count} punktów',
|
||||
'tours.import.previewNoHeights': 'Brak wysokości w pliku',
|
||||
'tours.import.previewLeftOut': 'Nie zaimportowano',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Wycieczki powstają tylko z linii. Pinezki, obszary, nakładki i odnośniki do innych plików zostają pominięte i nic nie jest wczytywane z zewnątrz.',
|
||||
'tours.import.leftOutPoints': '{count} pinezki',
|
||||
'tours.import.leftOutPoints.one': '{count} pinezka',
|
||||
'tours.import.leftOutPoints.few': '{count} pinezki',
|
||||
'tours.import.leftOutPoints.many': '{count} pinezek',
|
||||
'tours.import.leftOutAreas': '{count} obszaru',
|
||||
'tours.import.leftOutAreas.one': '{count} obszar',
|
||||
'tours.import.leftOutAreas.few': '{count} obszary',
|
||||
'tours.import.leftOutAreas.many': '{count} obszarów',
|
||||
'tours.import.leftOutOther': '{count} innego elementu',
|
||||
'tours.import.leftOutOther.one': '{count} inny element',
|
||||
'tours.import.leftOutOther.few': '{count} inne elementy',
|
||||
'tours.import.leftOutOther.many': '{count} innych elementów',
|
||||
'tours.import.unreadableIn':
|
||||
'Nie można odczytać pliku {name}: jest uszkodzony albo nie jest plikiem GPX, KML ani KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'W pliku {name} nie ma pliku KML',
|
||||
'tours.import.archiveTooLargeIn': 'Plik {name} po rozpakowaniu jest za duży',
|
||||
'tours.import.previewDuplicate': 'Już jest w podróży',
|
||||
'tours.import.previewAllDuplicates': 'Wszystkie ślady są już w podróży, nie ma czego importować.',
|
||||
'tours.import.previewAs': 'Importuj jako',
|
||||
|
||||
+31
-12
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Режим добавления',
|
||||
'tours.title': 'Туры',
|
||||
'tours.empty.title': 'Туров пока нет',
|
||||
'tours.empty.body': 'Для начала спланируйте тур или импортируйте файл GPX.',
|
||||
'tours.empty.body': 'Для начала спланируйте тур или импортируйте файл GPX или KML.',
|
||||
'tours.addToDay': 'Добавить к дню',
|
||||
'tours.addToDay.comingSoon': 'Назначайте туры на дни поездки из списка туров.',
|
||||
'tours.addToDay.pickDay': 'Выберите день',
|
||||
@@ -20,24 +20,24 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'используйте с осторожностью',
|
||||
'tours.caution.tooltip': 'Данные о высоте или показателях неполные — импорт с оговорками',
|
||||
'tours.import.caution': 'Импорт с оговорками — данные о высоте или показателях неполные',
|
||||
'tours.import.success': 'Импортировано {count} тура из GPX',
|
||||
'tours.import.success.one': 'Импортирован {count} тур из GPX',
|
||||
'tours.import.success.few': 'Импортировано {count} тура из GPX',
|
||||
'tours.import.success.many': 'Импортировано {count} туров из GPX',
|
||||
'tours.import.success': 'Импортировано {count} тура',
|
||||
'tours.import.success.one': 'Импортирован {count} тур',
|
||||
'tours.import.success.few': 'Импортировано {count} тура',
|
||||
'tours.import.success.many': 'Импортировано {count} туров',
|
||||
'tours.import.noTrackIn': 'В файле {name} не найден трек или маршрут',
|
||||
'tours.import.errorIn': 'Не удалось импортировать {name}',
|
||||
'tours.import.button': 'Импорт GPX',
|
||||
'tours.import.hint': 'Импортировать файлы GPX как туры этой поездки',
|
||||
'tours.import.unsupported': 'Как туры можно импортировать только файлы GPX.',
|
||||
'tours.import.button': 'Импорт GPX или KML',
|
||||
'tours.import.hint': 'Импортировать файлы GPX, KML или KMZ как туры этой поездки',
|
||||
'tours.import.unsupported': 'Как туры можно импортировать только файлы GPX, KML и KMZ.',
|
||||
'tours.import.skipped': 'Пропущено {count} трека: они уже есть в этой поездке',
|
||||
'tours.import.skipped.one': 'Пропущен {count} трек: он уже есть в этой поездке',
|
||||
'tours.import.skipped.few': 'Пропущено {count} трека: они уже есть в этой поездке',
|
||||
'tours.import.skipped.many': 'Пропущено {count} треков: они уже есть в этой поездке',
|
||||
'tours.import.allSkipped': 'Всё из этого файла уже есть в этой поездке.',
|
||||
'tours.import.drop': 'Перетащите сюда файлы GPX, чтобы импортировать их',
|
||||
'tours.import.drop': 'Перетащите сюда файлы GPX, KML или KMZ, чтобы импортировать их',
|
||||
'tours.import.dropHint': 'Каждый трек или маршрут в файле станет туром этой поездки.',
|
||||
'tours.import.dropNoPermission': 'Вы не можете добавлять туры в эту поездку.',
|
||||
'tours.import.importing': 'Импорт GPX…',
|
||||
'tours.import.importing': 'Импорт…',
|
||||
'tours.days.label': 'Дни поездки',
|
||||
'tours.days.none': 'Пока не назначен ни на один день поездки.',
|
||||
'tours.days.short': 'Дни {days}',
|
||||
@@ -106,7 +106,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Спланировать новый тур',
|
||||
'tours.planner.addTour': 'Добавить маршрут',
|
||||
'tours.planner.addTourHint':
|
||||
'Спланировать новый маршрут, импортировать файлы GPX или прогулку из Google Maps, или перенести маршрут из другой поездки',
|
||||
'Спланировать новый маршрут, импортировать файлы GPX или KML или прогулку из Google Maps, или перенести маршрут из другой поездки',
|
||||
'tours.planner.neutralTitle': 'Спланировать тур',
|
||||
'tours.planner.neutralBody': 'Начните новый маршрут и нажимайте на карту, чтобы расставить точки.',
|
||||
'tours.planner.newTitle': 'Новый тур',
|
||||
@@ -247,6 +247,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Происхождение',
|
||||
'tours.provenance.source': 'Источник',
|
||||
'tours.provenance.importedAt': 'Импортировано',
|
||||
'tours.provenance.fileImport': 'Импорт {format}',
|
||||
'tours.provenance.createdAt': 'Создано',
|
||||
'tours.provenance.externalUpdated': 'Изменено в wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -806,7 +807,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Это данные карты, а не сводка: они ничего не говорят о том, что открыто или подготовлено, о снеге и лавинах.',
|
||||
'tours.map.pisteFullKey': 'Полная легенда на OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Предпросмотр GPX',
|
||||
'tours.import.previewTitle': 'Предпросмотр импорта',
|
||||
'tours.import.previewHint': 'Пока ничего не сохранено. Проверьте маршрут и затем импортируйте.',
|
||||
'tours.import.previewFiles': '{count} файла',
|
||||
'tours.import.previewFiles.one': '{count} файл',
|
||||
@@ -821,6 +822,24 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints.few': '{count} точки',
|
||||
'tours.import.previewPoints.many': '{count} точек',
|
||||
'tours.import.previewNoHeights': 'В файле нет высот',
|
||||
'tours.import.previewLeftOut': 'Не импортируется',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Туры строятся только из линий. Метки, области, наложения и ссылки на другие файлы не переносятся, и ничего не загружается извне.',
|
||||
'tours.import.leftOutPoints': '{count} метки',
|
||||
'tours.import.leftOutPoints.one': '{count} метка',
|
||||
'tours.import.leftOutPoints.few': '{count} метки',
|
||||
'tours.import.leftOutPoints.many': '{count} меток',
|
||||
'tours.import.leftOutAreas': '{count} области',
|
||||
'tours.import.leftOutAreas.one': '{count} область',
|
||||
'tours.import.leftOutAreas.few': '{count} области',
|
||||
'tours.import.leftOutAreas.many': '{count} областей',
|
||||
'tours.import.leftOutOther': '{count} других элемента',
|
||||
'tours.import.leftOutOther.one': '{count} другой элемент',
|
||||
'tours.import.leftOutOther.few': '{count} других элемента',
|
||||
'tours.import.leftOutOther.many': '{count} других элементов',
|
||||
'tours.import.unreadableIn': 'Не удалось прочитать {name}: файл повреждён или это не GPX, KML или KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'В {name} не найден файл KML',
|
||||
'tours.import.archiveTooLargeIn': '{name} слишком велик после распаковки',
|
||||
'tours.import.previewDuplicate': 'Уже в поездке',
|
||||
'tours.import.previewAllDuplicates': 'Все треки уже в поездке, импортировать нечего.',
|
||||
'tours.import.previewAs': 'Импортировать как',
|
||||
|
||||
+27
-11
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Zobrazenie pridávania',
|
||||
'tours.title': 'Túry',
|
||||
'tours.empty.title': 'Zatiaľ žiadne túry',
|
||||
'tours.empty.body': 'Začnite plánovaním túry alebo importom súboru GPX.',
|
||||
'tours.empty.body': 'Začnite plánovaním túry alebo importom súboru GPX či KML.',
|
||||
'tours.addToDay': 'Pridať k dňu',
|
||||
'tours.addToDay.comingSoon': 'Priraďte túry k dňu cesty zo zoznamu túr.',
|
||||
'tours.addToDay.pickDay': 'Vyberte deň',
|
||||
@@ -20,22 +20,22 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'Používajte opatrne',
|
||||
'tours.caution.tooltip': 'Neúplné výškové údaje alebo metriky – importované s upozornením',
|
||||
'tours.import.caution': 'Importované s upozornením – neúplné výškové údaje alebo metriky',
|
||||
'tours.import.success': '{count} túr importovaných z GPX',
|
||||
'tours.import.success.one': '{count} túra importovaná z GPX',
|
||||
'tours.import.success.few': '{count} túry importované z GPX',
|
||||
'tours.import.success': '{count} túr importovaných',
|
||||
'tours.import.success.one': '{count} túra importovaná',
|
||||
'tours.import.success.few': '{count} túry importované',
|
||||
'tours.import.noTrackIn': 'V súbore {name} sa nenašla žiadna stopa ani trasa',
|
||||
'tours.import.errorIn': 'Súbor {name} sa nepodarilo importovať',
|
||||
'tours.import.button': 'Importovať GPX',
|
||||
'tours.import.hint': 'Importujte súbory GPX ako túry tejto cesty',
|
||||
'tours.import.unsupported': 'Ako túry sa dajú importovať iba súbory GPX.',
|
||||
'tours.import.button': 'Importovať GPX alebo KML',
|
||||
'tours.import.hint': 'Importujte súbory GPX, KML alebo KMZ ako túry tejto cesty',
|
||||
'tours.import.unsupported': 'Ako túry sa dajú importovať iba súbory GPX, KML a KMZ.',
|
||||
'tours.import.skipped': '{count} stôp už v tejto ceste bolo, preto sa preskočili',
|
||||
'tours.import.skipped.one': '{count} stopa už v tejto ceste bola, preto sa preskočila',
|
||||
'tours.import.skipped.few': '{count} stopy už v tejto ceste boli, preto sa preskočili',
|
||||
'tours.import.allSkipped': 'Všetko z tohto súboru už v tejto ceste je.',
|
||||
'tours.import.drop': 'Pustite sem súbory GPX a importujte ich',
|
||||
'tours.import.drop': 'Pustite sem súbory GPX, KML alebo KMZ a importujte ich',
|
||||
'tours.import.dropHint': 'Každá stopa alebo trasa v súbore sa stane túrou tejto cesty.',
|
||||
'tours.import.dropNoPermission': 'Do tejto cesty nemôžete pridávať túry.',
|
||||
'tours.import.importing': 'Importuje sa GPX…',
|
||||
'tours.import.importing': 'Importuje sa…',
|
||||
'tours.days.label': 'Dni cesty',
|
||||
'tours.days.none': 'Zatiaľ nie je priradená k žiadnemu dňu cesty.',
|
||||
'tours.days.short': 'Dni {days}',
|
||||
@@ -100,7 +100,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Naplánovať novú túru',
|
||||
'tours.planner.addTour': 'Pridať túru',
|
||||
'tours.planner.addTourHint':
|
||||
'Naplánuj novú túru, importuj súbory GPX alebo prechádzku z Google Maps, alebo prevezmi túru z inej cesty',
|
||||
'Naplánuj novú túru, importuj súbory GPX alebo KML či prechádzku z Google Maps, alebo prevezmi túru z inej cesty',
|
||||
'tours.planner.neutralTitle': 'Naplánovať túru',
|
||||
'tours.planner.neutralBody': 'Začnite novú túru a kliknutím do mapy umiestnite jej body.',
|
||||
'tours.planner.newTitle': 'Nová túra',
|
||||
@@ -236,6 +236,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Pôvod',
|
||||
'tours.provenance.source': 'Zdroj',
|
||||
'tours.provenance.importedAt': 'Importované',
|
||||
'tours.provenance.fileImport': 'Import {format}',
|
||||
'tours.provenance.createdAt': 'Vytvorené',
|
||||
'tours.provenance.externalUpdated': 'Zmenené vo wanderer',
|
||||
'tours.provenance.externalUpdatedHint': 'Dátum zmeny, ktorý wanderer uviedol pri importe. Náznak, nie dôkaz verzie.',
|
||||
@@ -781,7 +782,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Mapové údaje, nie spravodajstvo: nič o tom, čo je otvorené alebo upravené, o snehu ani lavínach.',
|
||||
'tours.map.pisteFullKey': 'Celá legenda na OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Náhľad GPX',
|
||||
'tours.import.previewTitle': 'Náhľad importu',
|
||||
'tours.import.previewHint': 'Zatiaľ nie je nič uložené. Skontrolujte priebeh a potom importujte.',
|
||||
'tours.import.previewFiles': '{count} súborov',
|
||||
'tours.import.previewFiles.one': '{count} súbor',
|
||||
@@ -793,6 +794,21 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints.one': '{count} bod',
|
||||
'tours.import.previewPoints.few': '{count} body',
|
||||
'tours.import.previewNoHeights': 'V súbore nie sú výšky',
|
||||
'tours.import.previewLeftOut': 'Neimportované',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Túry vznikajú len z čiar. Špendlíky, plochy, prekrytia a odkazy na iné súbory zostávajú bokom a nič sa nenačítava odinakiaľ.',
|
||||
'tours.import.leftOutPoints': '{count} špendlíkov',
|
||||
'tours.import.leftOutPoints.one': '{count} špendlík',
|
||||
'tours.import.leftOutPoints.few': '{count} špendlíky',
|
||||
'tours.import.leftOutAreas': '{count} plôch',
|
||||
'tours.import.leftOutAreas.one': '{count} plocha',
|
||||
'tours.import.leftOutAreas.few': '{count} plochy',
|
||||
'tours.import.leftOutOther': '{count} ďalších položiek',
|
||||
'tours.import.leftOutOther.one': '{count} ďalšia položka',
|
||||
'tours.import.leftOutOther.few': '{count} ďalšie položky',
|
||||
'tours.import.unreadableIn': 'Súbor {name} sa nedá prečítať: je poškodený alebo nie je GPX, KML ani KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'V súbore {name} nie je žiadny súbor KML',
|
||||
'tours.import.archiveTooLargeIn': 'Súbor {name} je po rozbalení príliš veľký',
|
||||
'tours.import.previewDuplicate': 'Už je v ceste',
|
||||
'tours.import.previewAllDuplicates': 'Všetky trasy už v ceste sú, nie je čo importovať.',
|
||||
'tours.import.previewAs': 'Importovať ako',
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Lägg till vy',
|
||||
'tours.title': 'Turer',
|
||||
'tours.empty.title': 'Inga turer ännu',
|
||||
'tours.empty.body': 'Planera en tur eller importera en GPX-fil för att komma igång.',
|
||||
'tours.empty.body': 'Planera en tur eller importera en GPX- eller KML-fil för att komma igång.',
|
||||
'tours.addToDay': 'Lägg till på en dag',
|
||||
'tours.addToDay.comingSoon': 'Tilldela turer till en resdag från turlistan.',
|
||||
'tours.addToDay.pickDay': 'Välj en dag',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'använd med försiktighet',
|
||||
'tours.caution.tooltip': 'Ofullständiga höjddata eller mätvärden: importerad med förbehåll',
|
||||
'tours.import.caution': 'Importerad med förbehåll: ofullständiga höjddata eller mätvärden',
|
||||
'tours.import.success': '{count} turer importerade från GPX',
|
||||
'tours.import.success.one': '{count} tur importerad från GPX',
|
||||
'tours.import.success': '{count} turer importerade',
|
||||
'tours.import.success.one': '{count} tur importerad',
|
||||
'tours.import.noTrackIn': 'Inget spår eller ingen rutt hittades i {name}',
|
||||
'tours.import.errorIn': 'Det gick inte att importera {name}',
|
||||
'tours.import.button': 'Importera GPX',
|
||||
'tours.import.hint': 'Importera GPX-filer som turer i den här resan',
|
||||
'tours.import.unsupported': 'Endast GPX-filer kan importeras som turer.',
|
||||
'tours.import.button': 'Importera GPX eller KML',
|
||||
'tours.import.hint': 'Importera GPX-, KML- eller KMZ-filer som turer i den här resan',
|
||||
'tours.import.unsupported': 'Endast GPX-, KML- och KMZ-filer kan importeras som turer.',
|
||||
'tours.import.skipped': '{count} spår fanns redan i den här resan och hoppades över',
|
||||
'tours.import.skipped.one': '{count} spår fanns redan i den här resan och hoppades över',
|
||||
'tours.import.allSkipped': 'Allt i den filen finns redan i den här resan.',
|
||||
'tours.import.drop': 'Släpp GPX-filer för att importera dem',
|
||||
'tours.import.drop': 'Släpp GPX-, KML- eller KMZ-filer för att importera dem',
|
||||
'tours.import.dropHint': 'Varje spår eller rutt i en fil blir en tur i den här resan.',
|
||||
'tours.import.dropNoPermission': 'Du kan inte lägga till turer i den här resan.',
|
||||
'tours.import.importing': 'Importerar GPX…',
|
||||
'tours.import.importing': 'Importerar…',
|
||||
'tours.days.label': 'Resdagar',
|
||||
'tours.days.none': 'Inte på någon dag i resan än.',
|
||||
'tours.days.short': 'Dagar {days}',
|
||||
@@ -96,7 +96,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Planera ny tur',
|
||||
'tours.planner.addTour': 'Lägg till tur',
|
||||
'tours.planner.addTourHint':
|
||||
'Planera en ny tur, importera GPX-filer eller en promenad från Google Maps, eller ta över en tur från en annan resa',
|
||||
'Planera en ny tur, importera GPX- eller KML-filer eller en promenad från Google Maps, eller ta över en tur från en annan resa',
|
||||
'tours.planner.neutralTitle': 'Planera en tur',
|
||||
'tours.planner.neutralBody': 'Starta en ny tur och klicka på kartan för att placera dess punkter.',
|
||||
'tours.planner.newTitle': 'Ny tur',
|
||||
@@ -231,6 +231,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Ursprung',
|
||||
'tours.provenance.source': 'Källa',
|
||||
'tours.provenance.importedAt': 'Importerad',
|
||||
'tours.provenance.fileImport': '{format}-import',
|
||||
'tours.provenance.createdAt': 'Skapad',
|
||||
'tours.provenance.externalUpdated': 'Ändrad i wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -779,7 +780,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Kartdata, ingen lägesrapport: inget om vad som är öppet eller preparerat, snö eller laviner.',
|
||||
'tours.map.pisteFullKey': 'Hela förklaringen på OpenSnowMap',
|
||||
'tours.import.previewTitle': 'GPX-förhandsvisning',
|
||||
'tours.import.previewTitle': 'Förhandsvisning av importen',
|
||||
'tours.import.previewHint': 'Inget är sparat ännu. Kontrollera sträckningen och importera sedan.',
|
||||
'tours.import.previewFiles': '{count} filer',
|
||||
'tours.import.previewFiles.one': '{count} fil',
|
||||
@@ -788,6 +789,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} punkter',
|
||||
'tours.import.previewPoints.one': '{count} punkt',
|
||||
'tours.import.previewNoHeights': 'Inga höjder i filen',
|
||||
'tours.import.previewLeftOut': 'Importeras inte',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Turer består bara av linjer. Nålar, områden, överlägg och länkar till andra filer lämnas utanför, och inget hämtas från någon annanstans.',
|
||||
'tours.import.leftOutPoints': '{count} nålar',
|
||||
'tours.import.leftOutPoints.one': '{count} nål',
|
||||
'tours.import.leftOutAreas': '{count} områden',
|
||||
'tours.import.leftOutAreas.one': '{count} område',
|
||||
'tours.import.leftOutOther': '{count} andra objekt',
|
||||
'tours.import.leftOutOther.one': '{count} annat objekt',
|
||||
'tours.import.unreadableIn': 'Det gick inte att läsa {name}: filen är skadad eller inte GPX, KML eller KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Ingen KML-fil hittades i {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} är för stor när den packats upp',
|
||||
'tours.import.previewDuplicate': 'Finns redan i resan',
|
||||
'tours.import.previewAllDuplicates': 'Alla spår finns redan i resan, det finns inget att importera.',
|
||||
'tours.import.previewAs': 'Importera som',
|
||||
|
||||
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'มุมมองการเพิ่ม',
|
||||
'tours.title': 'ทัวร์',
|
||||
'tours.empty.title': 'ยังไม่มีทัวร์',
|
||||
'tours.empty.body': 'วางแผนทัวร์หรือนำเข้าไฟล์ GPX เพื่อเริ่มต้น',
|
||||
'tours.empty.body': 'วางแผนทัวร์หรือนำเข้าไฟล์ GPX หรือ KML เพื่อเริ่มต้น',
|
||||
'tours.addToDay': 'เพิ่มไปยังวัน',
|
||||
'tours.addToDay.comingSoon': 'เพิ่มทัวร์จากรายการลงในวันของทริป',
|
||||
'tours.addToDay.pickDay': 'เลือกวัน',
|
||||
@@ -20,18 +20,18 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'ใช้ด้วยความระมัดระวัง',
|
||||
'tours.caution.tooltip': 'ข้อมูลระดับความสูงหรือค่าสถิติไม่ครบถ้วน ใช้ข้อมูลที่นำเข้าด้วยความระมัดระวัง',
|
||||
'tours.import.caution': 'นำเข้าแล้ว แต่ข้อมูลระดับความสูงหรือค่าสถิติไม่ครบถ้วน โปรดใช้ด้วยความระมัดระวัง',
|
||||
'tours.import.success': 'นำเข้าทัวร์ {count} รายการจาก GPX แล้ว',
|
||||
'tours.import.success': 'นำเข้าทัวร์ {count} รายการแล้ว',
|
||||
'tours.import.noTrackIn': 'ไม่พบแทร็กหรือเส้นทางใน {name}',
|
||||
'tours.import.errorIn': 'ไม่สามารถนำเข้า {name} ได้',
|
||||
'tours.import.button': 'นำเข้า GPX',
|
||||
'tours.import.hint': 'นำเข้าไฟล์ GPX เป็นทัวร์ของทริปนี้',
|
||||
'tours.import.unsupported': 'นำเข้าเป็นทัวร์ได้เฉพาะไฟล์ GPX เท่านั้น',
|
||||
'tours.import.button': 'นำเข้า GPX หรือ KML',
|
||||
'tours.import.hint': 'นำเข้าไฟล์ GPX, KML หรือ KMZ เป็นทัวร์ของทริปนี้',
|
||||
'tours.import.unsupported': 'นำเข้าเป็นทัวร์ได้เฉพาะไฟล์ GPX, KML และ KMZ เท่านั้น',
|
||||
'tours.import.skipped': 'ข้ามแทร็ก {count} รายการเพราะมีอยู่ในทริปนี้แล้ว',
|
||||
'tours.import.allSkipped': 'ทุกอย่างในไฟล์นั้นมีอยู่ในทริปนี้แล้ว',
|
||||
'tours.import.drop': 'วางไฟล์ GPX ที่นี่เพื่อนำเข้า',
|
||||
'tours.import.drop': 'วางไฟล์ GPX, KML หรือ KMZ ที่นี่เพื่อนำเข้า',
|
||||
'tours.import.dropHint': 'แทร็กหรือเส้นทางแต่ละรายการในไฟล์จะกลายเป็นทัวร์ของทริปนี้',
|
||||
'tours.import.dropNoPermission': 'คุณไม่สามารถเพิ่มทัวร์ลงในทริปนี้ได้',
|
||||
'tours.import.importing': 'กำลังนำเข้า GPX…',
|
||||
'tours.import.importing': 'กำลังนำเข้า…',
|
||||
'tours.days.label': 'วันในทริป',
|
||||
'tours.days.none': 'ยังไม่ได้อยู่ในวันใดของทริป',
|
||||
'tours.days.short': 'วันที่ {days}',
|
||||
@@ -89,7 +89,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'วางแผนทัวร์ใหม่',
|
||||
'tours.planner.addTour': 'เพิ่มทริป',
|
||||
'tours.planner.addTourHint':
|
||||
'วางแผนทริปใหม่ นำเข้าไฟล์ GPX หรือทริปเดินเท้าจาก Google Maps หรือรับทริปมาจากการเดินทางอื่น',
|
||||
'วางแผนทริปใหม่ นำเข้าไฟล์ GPX หรือ KML หรือทริปเดินเท้าจาก Google Maps หรือรับทริปมาจากการเดินทางอื่น',
|
||||
'tours.planner.neutralTitle': 'วางแผนทัวร์',
|
||||
'tours.planner.neutralBody': 'เริ่มทัวร์ใหม่ แล้วคลิกแผนที่เพื่อวางจุดของเส้นทาง',
|
||||
'tours.planner.newTitle': 'ทัวร์ใหม่',
|
||||
@@ -222,6 +222,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'ที่มา',
|
||||
'tours.provenance.source': 'แหล่งข้อมูล',
|
||||
'tours.provenance.importedAt': 'นำเข้าเมื่อ',
|
||||
'tours.provenance.fileImport': 'นำเข้าจาก {format}',
|
||||
'tours.provenance.createdAt': 'สร้างเมื่อ',
|
||||
'tours.provenance.externalUpdated': 'มีการเปลี่ยนใน wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -749,12 +750,21 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'เป็นข้อมูลแผนที่ ไม่ใช่รายงานสด ไม่ได้บอกว่าที่ใดเปิดหรือเตรียมลานแล้ว หรือเรื่องหิมะและหิมะถล่ม',
|
||||
'tours.map.pisteFullKey': 'คำอธิบายฉบับเต็มบน OpenSnowMap',
|
||||
'tours.import.previewTitle': 'ตัวอย่าง GPX',
|
||||
'tours.import.previewTitle': 'ตัวอย่างการนำเข้า',
|
||||
'tours.import.previewHint': 'ยังไม่มีการบันทึกอะไร ตรวจสอบเส้นทางก่อน แล้วจึงนำเข้า',
|
||||
'tours.import.previewFiles': '{count} ไฟล์',
|
||||
'tours.import.previewTracks': '{count} แทร็ก',
|
||||
'tours.import.previewPoints': '{count} จุด',
|
||||
'tours.import.previewNoHeights': 'ไม่มีความสูงในไฟล์',
|
||||
'tours.import.previewLeftOut': 'ไม่ถูกนำเข้า',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'ทัวร์สร้างจากเส้นเท่านั้น หมุด พื้นที่ เลเยอร์ซ้อน และลิงก์ไปยังไฟล์อื่นจะไม่ถูกนำเข้า และจะไม่มีการโหลดสิ่งใดจากที่อื่น',
|
||||
'tours.import.leftOutPoints': 'หมุด {count} รายการ',
|
||||
'tours.import.leftOutAreas': 'พื้นที่ {count} รายการ',
|
||||
'tours.import.leftOutOther': 'รายการอื่น {count} รายการ',
|
||||
'tours.import.unreadableIn': 'ไม่สามารถอ่าน {name} ได้: ไฟล์เสียหายหรือไม่ใช่ GPX, KML หรือ KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'ไม่พบไฟล์ KML ใน {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} ใหญ่เกินไปเมื่อแตกไฟล์',
|
||||
'tours.import.previewDuplicate': 'มีอยู่ในทริปแล้ว',
|
||||
'tours.import.previewAllDuplicates': 'ทุกแทร็กมีอยู่ในทริปแล้ว จึงไม่มีอะไรให้นำเข้า',
|
||||
'tours.import.previewAs': 'นำเข้าเป็น',
|
||||
|
||||
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
|
||||
// placeholder spec; here only the general keys are.
|
||||
const groups = pluralGroups(Object.keys(english));
|
||||
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
|
||||
const GENERAL_KEY_COUNT = 1173;
|
||||
const GENERAL_KEY_COUNT = 1182;
|
||||
|
||||
function literalKeys(source: string): string[] {
|
||||
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
|
||||
|
||||
+23
-10
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Ekleme görünümü',
|
||||
'tours.title': 'Turlar',
|
||||
'tours.empty.title': 'Henüz tur yok',
|
||||
'tours.empty.body': 'Başlamak için bir tur planlayın veya GPX dosyası içe aktarın.',
|
||||
'tours.empty.body': 'Başlamak için bir tur planlayın veya GPX ya da KML dosyası içe aktarın.',
|
||||
'tours.addToDay': 'Güne ekle',
|
||||
'tours.addToDay.comingSoon': 'Tur listesinden turları seyahatin bir gününe atayın.',
|
||||
'tours.addToDay.pickDay': 'Gün seçin',
|
||||
@@ -20,20 +20,20 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'dikkatli kullanın',
|
||||
'tours.caution.tooltip': 'Yükseklik verileri veya ölçümler eksik – çekinceyle içe aktarıldı',
|
||||
'tours.import.caution': 'Çekinceyle içe aktarıldı – yükseklik verileri veya ölçümler eksik',
|
||||
'tours.import.success': 'GPX dosyasından {count} tur içe aktarıldı',
|
||||
'tours.import.success.one': 'GPX dosyasından {count} tur içe aktarıldı',
|
||||
'tours.import.success': '{count} tur içe aktarıldı',
|
||||
'tours.import.success.one': '{count} tur içe aktarıldı',
|
||||
'tours.import.noTrackIn': '{name} içinde iz veya rota bulunamadı',
|
||||
'tours.import.errorIn': '{name} içe aktarılamadı',
|
||||
'tours.import.button': 'GPX içe aktar',
|
||||
'tours.import.hint': 'GPX dosyalarını bu seyahatin turları olarak içe aktarın',
|
||||
'tours.import.unsupported': 'Tur olarak yalnızca GPX dosyaları içe aktarılabilir.',
|
||||
'tours.import.button': 'GPX veya KML içe aktar',
|
||||
'tours.import.hint': 'GPX, KML veya KMZ dosyalarını bu seyahatin turları olarak içe aktarın',
|
||||
'tours.import.unsupported': 'Tur olarak yalnızca GPX, KML ve KMZ dosyaları içe aktarılabilir.',
|
||||
'tours.import.skipped': '{count} iz bu seyahatte zaten vardı ve atlandı',
|
||||
'tours.import.skipped.one': '{count} iz bu seyahatte zaten vardı ve atlandı',
|
||||
'tours.import.allSkipped': 'Bu dosyadaki her şey bu seyahatte zaten var.',
|
||||
'tours.import.drop': 'İçe aktarmak için GPX dosyalarını bırakın',
|
||||
'tours.import.drop': 'İçe aktarmak için GPX, KML veya KMZ dosyalarını bırakın',
|
||||
'tours.import.dropHint': 'Bir dosyadaki her iz veya rota bu seyahatin bir turu olur.',
|
||||
'tours.import.dropNoPermission': 'Bu seyahate tur ekleyemezsiniz.',
|
||||
'tours.import.importing': 'GPX içe aktarılıyor…',
|
||||
'tours.import.importing': 'İçe aktarılıyor…',
|
||||
'tours.days.label': 'Seyahat günleri',
|
||||
'tours.days.none': 'Henüz seyahatin hiçbir gününde değil.',
|
||||
'tours.days.short': 'Günler {days}',
|
||||
@@ -95,7 +95,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Yeni tur planla',
|
||||
'tours.planner.addTour': 'Tur ekle',
|
||||
'tours.planner.addTourHint':
|
||||
"Yeni bir tur planla, GPX dosyaları ya da Google Maps'ten bir yürüyüş içe aktar veya başka bir seyahatten tur al",
|
||||
"Yeni bir tur planla, GPX ya da KML dosyaları veya Google Maps'ten bir yürüyüş içe aktar ya da başka bir seyahatten tur al",
|
||||
'tours.planner.neutralTitle': 'Tur planla',
|
||||
'tours.planner.neutralBody': 'Yeni bir tur başlatın ve noktalarını yerleştirmek için haritaya tıklayın.',
|
||||
'tours.planner.newTitle': 'Yeni tur',
|
||||
@@ -233,6 +233,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Köken',
|
||||
'tours.provenance.source': 'Kaynak',
|
||||
'tours.provenance.importedAt': 'İçe aktarıldı',
|
||||
'tours.provenance.fileImport': '{format} içe aktarımı',
|
||||
'tours.provenance.createdAt': 'Oluşturuldu',
|
||||
'tours.provenance.externalUpdated': 'wanderer üzerinde değişti',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -786,7 +787,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Harita verisidir, güncel rapor değil: neyin açık ya da hazırlanmış olduğu, kar ya da çığ hakkında bir şey söylemez.',
|
||||
'tours.map.pisteFullKey': 'OpenSnowMap’te tam açıklama',
|
||||
'tours.import.previewTitle': 'GPX önizlemesi',
|
||||
'tours.import.previewTitle': 'İçe aktarma önizlemesi',
|
||||
'tours.import.previewHint': 'Henüz hiçbir şey kaydedilmedi. Güzergâhı kontrol et, sonra içe aktar.',
|
||||
'tours.import.previewFiles': '{count} dosya',
|
||||
'tours.import.previewFiles.one': '{count} dosya',
|
||||
@@ -795,6 +796,18 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints': '{count} nokta',
|
||||
'tours.import.previewPoints.one': '{count} nokta',
|
||||
'tours.import.previewNoHeights': 'Dosyada yükseklik yok',
|
||||
'tours.import.previewLeftOut': 'İçe aktarılmaz',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Turlar yalnızca çizgilerden oluşur. Raptiyeler, alanlar, katmanlar ve başka dosyalara bağlantılar dışarıda kalır, başka bir yerden hiçbir şey yüklenmez.',
|
||||
'tours.import.leftOutPoints': '{count} raptiye',
|
||||
'tours.import.leftOutPoints.one': '{count} raptiye',
|
||||
'tours.import.leftOutAreas': '{count} alan',
|
||||
'tours.import.leftOutAreas.one': '{count} alan',
|
||||
'tours.import.leftOutOther': '{count} diğer öğe',
|
||||
'tours.import.leftOutOther.one': '{count} diğer öğe',
|
||||
'tours.import.unreadableIn': '{name} okunamadı: dosya bozuk ya da GPX, KML veya KMZ değil',
|
||||
'tours.import.archiveNoKmlIn': '{name} içinde KML dosyası bulunamadı',
|
||||
'tours.import.archiveTooLargeIn': '{name} açıldığında çok büyük',
|
||||
'tours.import.previewDuplicate': 'Zaten seyahatte',
|
||||
'tours.import.previewAllDuplicates': 'Tüm izler zaten seyahatte, içe aktarılacak bir şey yok.',
|
||||
'tours.import.previewAs': 'Şu olarak içe aktar',
|
||||
|
||||
+31
-12
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Режим додавання',
|
||||
'tours.title': 'Тури',
|
||||
'tours.empty.title': 'Турів поки немає',
|
||||
'tours.empty.body': 'Для початку сплануйте тур або імпортуйте файл GPX.',
|
||||
'tours.empty.body': 'Для початку сплануйте тур або імпортуйте файл GPX чи KML.',
|
||||
'tours.addToDay': 'Додати до дня',
|
||||
'tours.addToDay.comingSoon': 'Призначайте тури на дні подорожі зі списку турів.',
|
||||
'tours.addToDay.pickDay': 'Виберіть день',
|
||||
@@ -20,24 +20,24 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'використовуйте обережно',
|
||||
'tours.caution.tooltip': 'Дані про висоту або показники неповні — імпорт із застереженнями',
|
||||
'tours.import.caution': 'Імпорт із застереженнями — дані про висоту або показники неповні',
|
||||
'tours.import.success': 'Імпортовано {count} туру з GPX',
|
||||
'tours.import.success.one': 'Імпортовано {count} тур із GPX',
|
||||
'tours.import.success.few': 'Імпортовано {count} тури з GPX',
|
||||
'tours.import.success.many': 'Імпортовано {count} турів із GPX',
|
||||
'tours.import.success': 'Імпортовано {count} туру',
|
||||
'tours.import.success.one': 'Імпортовано {count} тур',
|
||||
'tours.import.success.few': 'Імпортовано {count} тури',
|
||||
'tours.import.success.many': 'Імпортовано {count} турів',
|
||||
'tours.import.noTrackIn': 'У файлі {name} не знайдено треку або маршруту',
|
||||
'tours.import.errorIn': 'Не вдалося імпортувати {name}',
|
||||
'tours.import.button': 'Імпорт GPX',
|
||||
'tours.import.hint': 'Імпортувати файли GPX як тури цієї подорожі',
|
||||
'tours.import.unsupported': 'Як тури можна імпортувати лише файли GPX.',
|
||||
'tours.import.button': 'Імпорт GPX або KML',
|
||||
'tours.import.hint': 'Імпортувати файли GPX, KML або KMZ як тури цієї подорожі',
|
||||
'tours.import.unsupported': 'Як тури можна імпортувати лише файли GPX, KML і KMZ.',
|
||||
'tours.import.skipped': 'Пропущено {count} треку: вони вже є в цій подорожі',
|
||||
'tours.import.skipped.one': 'Пропущено {count} трек: він уже є в цій подорожі',
|
||||
'tours.import.skipped.few': 'Пропущено {count} треки: вони вже є в цій подорожі',
|
||||
'tours.import.skipped.many': 'Пропущено {count} треків: вони вже є в цій подорожі',
|
||||
'tours.import.allSkipped': 'Усе з цього файлу вже є в цій подорожі.',
|
||||
'tours.import.drop': 'Перетягніть сюди файли GPX, щоб імпортувати їх',
|
||||
'tours.import.drop': 'Перетягніть сюди файли GPX, KML або KMZ, щоб імпортувати їх',
|
||||
'tours.import.dropHint': 'Кожен трек або маршрут у файлі стане туром цієї подорожі.',
|
||||
'tours.import.dropNoPermission': 'Ви не можете додавати тури до цієї подорожі.',
|
||||
'tours.import.importing': 'Імпорт GPX…',
|
||||
'tours.import.importing': 'Імпорт…',
|
||||
'tours.days.label': 'Дні подорожі',
|
||||
'tours.days.none': 'Ще не призначено на жоден день подорожі.',
|
||||
'tours.days.short': 'Дні {days}',
|
||||
@@ -105,7 +105,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Спланувати новий тур',
|
||||
'tours.planner.addTour': 'Додати маршрут',
|
||||
'tours.planner.addTourHint':
|
||||
'Спланувати новий маршрут, імпортувати файли GPX або прогулянку з Google Maps, або перенести маршрут з іншої подорожі',
|
||||
'Спланувати новий маршрут, імпортувати файли GPX чи KML або прогулянку з Google Maps, або перенести маршрут з іншої подорожі',
|
||||
'tours.planner.neutralTitle': 'Спланувати тур',
|
||||
'tours.planner.neutralBody': 'Почніть новий маршрут і натискайте на карту, щоб розставити точки.',
|
||||
'tours.planner.newTitle': 'Новий тур',
|
||||
@@ -244,6 +244,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Походження',
|
||||
'tours.provenance.source': 'Джерело',
|
||||
'tours.provenance.importedAt': 'Імпортовано',
|
||||
'tours.provenance.fileImport': 'Імпорт {format}',
|
||||
'tours.provenance.createdAt': 'Створено',
|
||||
'tours.provenance.externalUpdated': 'Змінено у wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -798,7 +799,7 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Це дані мапи, а не зведення: вони нічого не кажуть про те, що відкрито чи підготовлено, про сніг і лавини.',
|
||||
'tours.map.pisteFullKey': 'Повна легенда на OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Попередній перегляд GPX',
|
||||
'tours.import.previewTitle': 'Попередній перегляд імпорту',
|
||||
'tours.import.previewHint': 'Поки нічого не збережено. Перевірте маршрут, а потім імпортуйте.',
|
||||
'tours.import.previewFiles': '{count} файлу',
|
||||
'tours.import.previewFiles.one': '{count} файл',
|
||||
@@ -813,6 +814,24 @@ const tours: TranslationStrings = {
|
||||
'tours.import.previewPoints.few': '{count} точки',
|
||||
'tours.import.previewPoints.many': '{count} точок',
|
||||
'tours.import.previewNoHeights': 'У файлі немає висот',
|
||||
'tours.import.previewLeftOut': 'Не імпортується',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Тури складаються лише з ліній. Мітки, області, накладки й посилання на інші файли лишаються поза імпортом, і нічого не завантажується ззовні.',
|
||||
'tours.import.leftOutPoints': '{count} мітки',
|
||||
'tours.import.leftOutPoints.one': '{count} мітка',
|
||||
'tours.import.leftOutPoints.few': '{count} мітки',
|
||||
'tours.import.leftOutPoints.many': '{count} міток',
|
||||
'tours.import.leftOutAreas': '{count} області',
|
||||
'tours.import.leftOutAreas.one': '{count} область',
|
||||
'tours.import.leftOutAreas.few': '{count} області',
|
||||
'tours.import.leftOutAreas.many': '{count} областей',
|
||||
'tours.import.leftOutOther': '{count} інших елемента',
|
||||
'tours.import.leftOutOther.one': '{count} інший елемент',
|
||||
'tours.import.leftOutOther.few': '{count} інші елементи',
|
||||
'tours.import.leftOutOther.many': '{count} інших елементів',
|
||||
'tours.import.unreadableIn': 'Не вдалося прочитати {name}: файл пошкоджено або це не GPX, KML чи KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'У {name} не знайдено файлу KML',
|
||||
'tours.import.archiveTooLargeIn': '{name} завеликий після розпакування',
|
||||
'tours.import.previewDuplicate': 'Уже в подорожі',
|
||||
'tours.import.previewAllDuplicates': 'Усі треки вже в подорожі, імпортувати нічого.',
|
||||
'tours.import.previewAs': 'Імпортувати як',
|
||||
|
||||
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': 'Chế độ thêm',
|
||||
'tours.title': 'Chuyến tham quan',
|
||||
'tours.empty.title': 'Chưa có chuyến tham quan',
|
||||
'tours.empty.body': 'Lên kế hoạch cho một chuyến tham quan hoặc nhập tệp GPX để bắt đầu.',
|
||||
'tours.empty.body': 'Lên kế hoạch cho một chuyến tham quan hoặc nhập tệp GPX hay KML để bắt đầu.',
|
||||
'tours.addToDay': 'Thêm vào ngày',
|
||||
'tours.addToDay.comingSoon': 'Gán chuyến tham quan vào một ngày của chuyến đi từ danh sách chuyến tham quan.',
|
||||
'tours.addToDay.pickDay': 'Chọn ngày',
|
||||
@@ -20,19 +20,19 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': 'cần thận trọng khi sử dụng',
|
||||
'tours.caution.tooltip': 'Dữ liệu độ cao hoặc thông số chưa đầy đủ; đã nhập kèm cảnh báo',
|
||||
'tours.import.caution': 'Đã nhập kèm cảnh báo; dữ liệu độ cao hoặc thông số chưa đầy đủ',
|
||||
'tours.import.success': 'Đã nhập {count} chuyến tham quan từ GPX',
|
||||
'tours.import.success': 'Đã nhập {count} chuyến tham quan',
|
||||
'tours.import.noTrackIn': 'Không tìm thấy vệt hành trình hoặc tuyến đường trong {name}',
|
||||
'tours.import.errorIn': 'Không thể nhập {name}',
|
||||
'tours.import.button': 'Nhập GPX',
|
||||
'tours.import.hint': 'Nhập tệp GPX thành các chuyến tham quan của chuyến đi này',
|
||||
'tours.import.unsupported': 'Chỉ có thể nhập tệp GPX thành chuyến tham quan.',
|
||||
'tours.import.button': 'Nhập GPX hoặc KML',
|
||||
'tours.import.hint': 'Nhập tệp GPX, KML hoặc KMZ thành các chuyến tham quan của chuyến đi này',
|
||||
'tours.import.unsupported': 'Chỉ có thể nhập tệp GPX, KML và KMZ thành chuyến tham quan.',
|
||||
'tours.import.skipped': '{count} vệt hành trình đã có trong chuyến đi này nên được bỏ qua',
|
||||
'tours.import.allSkipped': 'Mọi nội dung trong tệp đó đã có trong chuyến đi này.',
|
||||
'tours.import.drop': 'Thả tệp GPX vào đây để nhập',
|
||||
'tours.import.drop': 'Thả tệp GPX, KML hoặc KMZ vào đây để nhập',
|
||||
'tours.import.dropHint':
|
||||
'Mỗi vệt hành trình hoặc tuyến đường trong tệp sẽ trở thành một chuyến tham quan của chuyến đi này.',
|
||||
'tours.import.dropNoPermission': 'Bạn không thể thêm chuyến tham quan vào chuyến đi này.',
|
||||
'tours.import.importing': 'Đang nhập GPX…',
|
||||
'tours.import.importing': 'Đang nhập…',
|
||||
'tours.days.label': 'Các ngày của chuyến đi',
|
||||
'tours.days.none': 'Chưa được gán vào ngày nào của chuyến đi.',
|
||||
'tours.days.short': 'Ngày {days}',
|
||||
@@ -96,7 +96,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.newTour': 'Lên kế hoạch tham quan mới',
|
||||
'tours.planner.addTour': 'Thêm chuyến đi',
|
||||
'tours.planner.addTourHint':
|
||||
'Lên kế hoạch chuyến mới, nhập tệp GPX hoặc lộ trình đi bộ từ Google Maps, hoặc lấy một chuyến từ hành trình khác',
|
||||
'Lên kế hoạch chuyến mới, nhập tệp GPX hoặc KML hay lộ trình đi bộ từ Google Maps, hoặc lấy một chuyến từ hành trình khác',
|
||||
'tours.planner.neutralTitle': 'Lên kế hoạch tham quan',
|
||||
'tours.planner.neutralBody': 'Bắt đầu chuyến đi mới và nhấp vào bản đồ để đặt các điểm.',
|
||||
'tours.planner.newTitle': 'Chuyến tham quan mới',
|
||||
@@ -232,6 +232,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': 'Nguồn gốc',
|
||||
'tours.provenance.source': 'Nguồn',
|
||||
'tours.provenance.importedAt': 'Đã nhập',
|
||||
'tours.provenance.fileImport': 'Nhập từ {format}',
|
||||
'tours.provenance.createdAt': 'Đã tạo',
|
||||
'tours.provenance.externalUpdated': 'Đã đổi trên wanderer',
|
||||
'tours.provenance.externalUpdatedHint':
|
||||
@@ -780,12 +781,21 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteStatic':
|
||||
'Dữ liệu bản đồ, không phải bản tin: không cho biết nơi nào đang mở hay đã được san tuyết, về tuyết hay lở tuyết.',
|
||||
'tours.map.pisteFullKey': 'Chú giải đầy đủ trên OpenSnowMap',
|
||||
'tours.import.previewTitle': 'Xem trước GPX',
|
||||
'tours.import.previewTitle': 'Xem trước khi nhập',
|
||||
'tours.import.previewHint': 'Chưa có gì được lưu. Hãy kiểm tra lộ trình rồi nhập.',
|
||||
'tours.import.previewFiles': '{count} tệp',
|
||||
'tours.import.previewTracks': '{count} vết',
|
||||
'tours.import.previewPoints': '{count} điểm',
|
||||
'tours.import.previewNoHeights': 'Tệp không có độ cao',
|
||||
'tours.import.previewLeftOut': 'Không được nhập',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'Chuyến tham quan chỉ được tạo từ các đường. Ghim, vùng, lớp phủ và liên kết tới tệp khác bị bỏ qua, và không có gì được tải từ nơi khác.',
|
||||
'tours.import.leftOutPoints': '{count} ghim',
|
||||
'tours.import.leftOutAreas': '{count} vùng',
|
||||
'tours.import.leftOutOther': '{count} mục khác',
|
||||
'tours.import.unreadableIn': 'Không thể đọc {name}: tệp bị hỏng hoặc không phải GPX, KML hay KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Không tìm thấy tệp KML trong {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} quá lớn sau khi giải nén',
|
||||
'tours.import.previewDuplicate': 'Đã có trong chuyến đi',
|
||||
'tours.import.previewAllDuplicates': 'Mọi vết đã có trong chuyến đi, không có gì để nhập.',
|
||||
'tours.import.previewAs': 'Nhập dưới dạng',
|
||||
|
||||
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': '新增檢視',
|
||||
'tours.title': '遊程',
|
||||
'tours.empty.title': '尚無遊程',
|
||||
'tours.empty.body': '規劃遊程或匯入 GPX 檔案,即可開始。',
|
||||
'tours.empty.body': '規劃遊程或匯入 GPX 或 KML 檔案,即可開始。',
|
||||
'tours.addToDay': '加入某一天',
|
||||
'tours.addToDay.comingSoon': '從遊程清單將活動安排到旅行的某一天。',
|
||||
'tours.addToDay.pickDay': '選擇日期',
|
||||
@@ -20,18 +20,18 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': '請謹慎使用',
|
||||
'tours.caution.tooltip': '海拔或指標資料不完整,已附警告匯入',
|
||||
'tours.import.caution': '已附警告匯入,海拔或指標資料不完整',
|
||||
'tours.import.success': '已從 GPX 匯入 {count} 個遊程',
|
||||
'tours.import.success': '已匯入 {count} 個遊程',
|
||||
'tours.import.noTrackIn': '{name} 中找不到軌跡或路線',
|
||||
'tours.import.errorIn': '無法匯入 {name}',
|
||||
'tours.import.button': '匯入 GPX',
|
||||
'tours.import.hint': '將 GPX 檔案匯入為這趟旅行的遊程',
|
||||
'tours.import.unsupported': '只有 GPX 檔案可以匯入為遊程。',
|
||||
'tours.import.button': '匯入 GPX 或 KML',
|
||||
'tours.import.hint': '將 GPX、KML 或 KMZ 檔案匯入為這趟旅行的遊程',
|
||||
'tours.import.unsupported': '只有 GPX、KML 和 KMZ 檔案可以匯入為遊程。',
|
||||
'tours.import.skipped': '有 {count} 條軌跡已在這趟旅行中,已略過',
|
||||
'tours.import.allSkipped': '這個檔案中的所有內容都已在這趟旅行中。',
|
||||
'tours.import.drop': '拖放 GPX 檔案以匯入',
|
||||
'tours.import.drop': '拖放 GPX、KML 或 KMZ 檔案以匯入',
|
||||
'tours.import.dropHint': '檔案中的每條軌跡或路線都會成為這趟旅行的一個遊程。',
|
||||
'tours.import.dropNoPermission': '你無法在這趟旅行中新增遊程。',
|
||||
'tours.import.importing': '正在匯入 GPX…',
|
||||
'tours.import.importing': '正在匯入…',
|
||||
'tours.days.label': '旅行日期',
|
||||
'tours.days.none': '尚未安排到旅行的任何一天。',
|
||||
'tours.days.short': '第 {days} 天',
|
||||
@@ -85,7 +85,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': '規劃遊程',
|
||||
'tours.planner.newTour': '規劃新遊程',
|
||||
'tours.planner.addTour': '新增行程',
|
||||
'tours.planner.addTourHint': '規劃新行程、匯入 GPX 檔案或 Google Maps 漫步,或從其他旅行中接管行程',
|
||||
'tours.planner.addTourHint': '規劃新行程、匯入 GPX 或 KML 檔案或 Google Maps 漫步,或從其他旅行中接管行程',
|
||||
'tours.planner.neutralTitle': '規劃遊程',
|
||||
'tours.planner.neutralBody': '開始新路線,點選地圖放置路線點。',
|
||||
'tours.planner.newTitle': '新遊程',
|
||||
@@ -215,6 +215,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': '來源',
|
||||
'tours.provenance.source': '出處',
|
||||
'tours.provenance.importedAt': '匯入時間',
|
||||
'tours.provenance.fileImport': '{format} 匯入',
|
||||
'tours.provenance.createdAt': '建立時間',
|
||||
'tours.provenance.externalUpdated': '在 wanderer 上已變更',
|
||||
'tours.provenance.externalUpdatedHint': 'wanderer 在匯入時提供的變更日期。只是提示,不能證明版本。',
|
||||
@@ -720,12 +721,21 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteLift': '纜車與索道',
|
||||
'tours.map.pisteStatic': '這是地圖資料,不是即時資訊:不說明是否開放或已壓雪,也不涉及積雪或雪崩。',
|
||||
'tours.map.pisteFullKey': 'OpenSnowMap 上的完整圖例',
|
||||
'tours.import.previewTitle': 'GPX 預覽',
|
||||
'tours.import.previewTitle': '匯入預覽',
|
||||
'tours.import.previewHint': '尚未儲存任何內容。請先檢查路線,再匯入。',
|
||||
'tours.import.previewFiles': '{count} 個檔案',
|
||||
'tours.import.previewTracks': '{count} 條軌跡',
|
||||
'tours.import.previewPoints': '{count} 個點',
|
||||
'tours.import.previewNoHeights': '檔案中沒有海拔',
|
||||
'tours.import.previewLeftOut': '不匯入',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'遊程只由線條構成。圖釘、區域、疊加層和指向其他檔案的連結不會匯入,也不會從別處載入任何內容。',
|
||||
'tours.import.leftOutPoints': '{count} 個圖釘',
|
||||
'tours.import.leftOutAreas': '{count} 個區域',
|
||||
'tours.import.leftOutOther': '其他 {count} 項',
|
||||
'tours.import.unreadableIn': '無法讀取 {name}:檔案已損毀,或不是 GPX、KML 或 KMZ',
|
||||
'tours.import.archiveNoKmlIn': '{name} 中沒有 KML 檔案',
|
||||
'tours.import.archiveTooLargeIn': '{name} 解壓縮後過大',
|
||||
'tours.import.previewDuplicate': '已在旅程中',
|
||||
'tours.import.previewAllDuplicates': '所有軌跡都已在旅程中,沒有可匯入的內容。',
|
||||
'tours.import.previewAs': '匯入為',
|
||||
|
||||
@@ -7,7 +7,7 @@ const tours: TranslationStrings = {
|
||||
'tours.mode.label': '添加视图',
|
||||
'tours.title': '游览',
|
||||
'tours.empty.title': '暂无游览活动',
|
||||
'tours.empty.body': '规划游览路线或导入 GPX 文件,即可开始。',
|
||||
'tours.empty.body': '规划游览路线或导入 GPX 或 KML 文件,即可开始。',
|
||||
'tours.addToDay': '添加到某天',
|
||||
'tours.addToDay.comingSoon': '从游览列表将活动安排到旅行的某一天。',
|
||||
'tours.addToDay.pickDay': '选择日期',
|
||||
@@ -20,18 +20,18 @@ const tours: TranslationStrings = {
|
||||
'tours.caution.badge': '请谨慎使用',
|
||||
'tours.caution.tooltip': '海拔或指标数据不完整,已带警告导入',
|
||||
'tours.import.caution': '已带警告导入,海拔或指标数据不完整',
|
||||
'tours.import.success': '已从 GPX 导入 {count} 条游览路线',
|
||||
'tours.import.success': '已导入 {count} 条游览路线',
|
||||
'tours.import.noTrackIn': '{name} 中未找到轨迹或路线',
|
||||
'tours.import.errorIn': '无法导入 {name}',
|
||||
'tours.import.button': '导入 GPX',
|
||||
'tours.import.hint': '将 GPX 文件导入为此旅行的游览路线',
|
||||
'tours.import.unsupported': '只有 GPX 文件可以导入为游览路线。',
|
||||
'tours.import.button': '导入 GPX 或 KML',
|
||||
'tours.import.hint': '将 GPX、KML 或 KMZ 文件导入为此旅行的游览路线',
|
||||
'tours.import.unsupported': '只有 GPX、KML 和 KMZ 文件可以导入为游览路线。',
|
||||
'tours.import.skipped': '有 {count} 条轨迹已在此旅行中,已跳过',
|
||||
'tours.import.allSkipped': '该文件中的所有内容都已在此旅行中。',
|
||||
'tours.import.drop': '拖放 GPX 文件以导入',
|
||||
'tours.import.drop': '拖放 GPX、KML 或 KMZ 文件以导入',
|
||||
'tours.import.dropHint': '文件中的每条轨迹或路线都会成为此旅行的一条游览路线。',
|
||||
'tours.import.dropNoPermission': '你无法向此旅行添加游览路线。',
|
||||
'tours.import.importing': '正在导入 GPX…',
|
||||
'tours.import.importing': '正在导入…',
|
||||
'tours.days.label': '旅行日期',
|
||||
'tours.days.none': '尚未安排到旅行的任何一天。',
|
||||
'tours.days.short': '第 {days} 天',
|
||||
@@ -86,7 +86,7 @@ const tours: TranslationStrings = {
|
||||
'tours.planner.title': '规划游览路线',
|
||||
'tours.planner.newTour': '规划新游览路线',
|
||||
'tours.planner.addTour': '添加行程',
|
||||
'tours.planner.addTourHint': '规划新行程、导入 GPX 文件或 Google Maps 漫步,或从其他旅行中接管行程',
|
||||
'tours.planner.addTourHint': '规划新行程、导入 GPX 或 KML 文件或 Google Maps 漫步,或从其他旅行中接管行程',
|
||||
'tours.planner.neutralTitle': '规划游览路线',
|
||||
'tours.planner.neutralBody': '开始新路线,点击地图放置路线点。',
|
||||
'tours.planner.newTitle': '新游览路线',
|
||||
@@ -216,6 +216,7 @@ const tours: TranslationStrings = {
|
||||
'tours.provenance.label': '来源',
|
||||
'tours.provenance.source': '源',
|
||||
'tours.provenance.importedAt': '导入时间',
|
||||
'tours.provenance.fileImport': '{format} 导入',
|
||||
'tours.provenance.createdAt': '创建时间',
|
||||
'tours.provenance.externalUpdated': '在 wanderer 上已更改',
|
||||
'tours.provenance.externalUpdatedHint': 'wanderer 在导入时给出的更改日期。只是提示,不能证明版本。',
|
||||
@@ -721,12 +722,21 @@ const tours: TranslationStrings = {
|
||||
'tours.map.pisteLift': '缆车和索道',
|
||||
'tours.map.pisteStatic': '这是地图数据,不是实时信息:不说明是否开放或已压雪,也不涉及积雪或雪崩。',
|
||||
'tours.map.pisteFullKey': 'OpenSnowMap 上的完整图例',
|
||||
'tours.import.previewTitle': 'GPX 预览',
|
||||
'tours.import.previewTitle': '导入预览',
|
||||
'tours.import.previewHint': '尚未保存任何内容。请先检查路线,再导入。',
|
||||
'tours.import.previewFiles': '{count} 个文件',
|
||||
'tours.import.previewTracks': '{count} 条轨迹',
|
||||
'tours.import.previewPoints': '{count} 个点',
|
||||
'tours.import.previewNoHeights': '文件中没有海拔',
|
||||
'tours.import.previewLeftOut': '不导入',
|
||||
'tours.import.previewLeftOutHint':
|
||||
'游览路线只由线条构成。图钉、区域、叠加层和指向其他文件的链接不会导入,也不会从别处加载任何内容。',
|
||||
'tours.import.leftOutPoints': '{count} 个图钉',
|
||||
'tours.import.leftOutAreas': '{count} 个区域',
|
||||
'tours.import.leftOutOther': '其他 {count} 项',
|
||||
'tours.import.unreadableIn': '无法读取 {name}:文件已损坏,或不是 GPX、KML 或 KMZ',
|
||||
'tours.import.archiveNoKmlIn': '{name} 中没有 KML 文件',
|
||||
'tours.import.archiveTooLargeIn': '{name} 解压后过大',
|
||||
'tours.import.previewDuplicate': '已在行程中',
|
||||
'tours.import.previewAllDuplicates': '所有轨迹都已在行程中,没有可导入的内容。',
|
||||
'tours.import.previewAs': '导入为',
|
||||
|
||||
@@ -55,3 +55,34 @@ describe('Tours GPX preview', () => {
|
||||
expect(tourGpxPreviewResponseSchema.parse(preview)).toEqual(preview);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tours KML preview (IO-02)', () => {
|
||||
const track = {
|
||||
name: 'Ridge (1/2)',
|
||||
geometry: [
|
||||
[47, 11],
|
||||
[47.01, 11.01],
|
||||
],
|
||||
point_count: 2,
|
||||
distance_km: 1.4,
|
||||
elevation_gain_m: null,
|
||||
elevation_loss_m: null,
|
||||
duplicate: false,
|
||||
};
|
||||
|
||||
it('counts what a KML file held besides its lines, and a GPX preview may leave that out', () => {
|
||||
const kml = { tracks: [track], left_out: { points: 3, areas: 1, other: 0 } };
|
||||
expect(tourGpxPreviewResponseSchema.parse(kml)).toEqual(kml);
|
||||
expect(tourGpxPreviewResponseSchema.parse({ tracks: [track] })).toEqual({ tracks: [track] });
|
||||
});
|
||||
|
||||
it('refuses a count that is negative or not whole', () => {
|
||||
for (const left_out of [
|
||||
{ points: -1, areas: 0, other: 0 },
|
||||
{ points: 0, areas: 0.5, other: 0 },
|
||||
{ points: 0, areas: 0 },
|
||||
]) {
|
||||
expect(tourGpxPreviewResponseSchema.safeParse({ tracks: [track], left_out }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,10 @@ import { z } from 'zod';
|
||||
* runs the file through the same parser the import uses and answers what it
|
||||
* found; nothing is written. The import that follows takes the type the user
|
||||
* chose for it.
|
||||
*
|
||||
* IO-02: the same routes read a KML or KMZ file (by its extension) for its
|
||||
* lines, each line one track, and say what else the file held that no tour is
|
||||
* made of.
|
||||
*/
|
||||
|
||||
const previewPointSchema = z.union([z.tuple([z.number(), z.number()]), z.tuple([z.number(), z.number(), z.number()])]);
|
||||
@@ -39,8 +43,23 @@ export const tourGpxPreviewTrackSchema = z.object({
|
||||
});
|
||||
export type TourGpxPreviewTrack = z.infer<typeof tourGpxPreviewTrackSchema>;
|
||||
|
||||
const leftOutCount = z.number().int().nonnegative();
|
||||
|
||||
/** What a KML or KMZ file held besides its lines, counted by kind; none of it is imported. */
|
||||
export const tourFileLeftOutSchema = z.object({
|
||||
/** Single points (pins). */
|
||||
points: leftOutCount,
|
||||
/** Polygons. */
|
||||
areas: leftOutCount,
|
||||
/** Models, overlays, links to other files (never followed), placemarks without a geometry, too short lines. */
|
||||
other: leftOutCount,
|
||||
});
|
||||
export type TourFileLeftOut = z.infer<typeof tourFileLeftOutSchema>;
|
||||
|
||||
export const tourGpxPreviewResponseSchema = z.object({
|
||||
tracks: z.array(tourGpxPreviewTrackSchema),
|
||||
/** Only a KML or KMZ preview counts what it leaves out; a GPX preview never counted its loose waypoints. */
|
||||
left_out: tourFileLeftOutSchema.optional(),
|
||||
});
|
||||
export type TourGpxPreviewResponse = z.infer<typeof tourGpxPreviewResponseSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user