mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(places): export a trip as GPX (#1907)
* feat(places): hand a trip back as GPX TREK reads GPX but never wrote it, so a planned trip could not reach the offline map or the handheld it was planned for. The importer already fixes the mapping: a wpt is a place, a rte or trk is a place with route_geometry, so writing is that reversed. Days have no import counterpart and become routes of their stops in order, which is the part that puts a plan on a device. XMLBuilder comes from the same fast-xml-parser the importer parses with, so nothing new is pulled in. * feat(dayplan): a GPX button beside PDF and ICS Scopes are worded by what someone wants on their device rather than by the GPX element: the whole trip, just the places for an offline map, just the days to follow a plan. An empty trip says so instead of reporting a failed download. * feat(mobile): GPX in the export sheet, which is no longer calendar-only The sheet held the PDF export next to the calendar entries and was still called Export Calendar; with GPX beside them the name was simply wrong. * i18n(dayplan): translate the GPX export into all locales * docs(map): document the GPX export * fix(dayplan): drop the GPX tooltip and centre both export menus The tooltip sat under the cursor exactly where the menu opens, so hovering the button produced both at once. The label lives on as an aria-label. Both menus now open centred under their button instead of hanging off its right edge.
This commit is contained in:
@@ -372,4 +372,83 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
|
||||
})
|
||||
|
||||
// ── GPX export (#1442) ────────────────────────────────────────────────────
|
||||
describe('GPX menu', () => {
|
||||
let clickedHref: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
clickedHref = null
|
||||
// jsdom has neither of these, and an anchor click would navigate.
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:gpx')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
clickedHref = this.href
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const okResponse = () => ({ ok: true, status: 200, blob: async () => new Blob(['<gpx/>']) }) as unknown as Response
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-025: the menu offers the three scopes on hover', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
expect(screen.queryByText('dayplan.gpxAll')).not.toBeInTheDocument()
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
expect(screen.getByText('dayplan.gpxAll')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.gpxPlaces')).toBeInTheDocument()
|
||||
expect(screen.getByText('dayplan.gpxDays')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-026: each scope asks the server for exactly its own selection', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = vi.fn(async () => okResponse())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
|
||||
for (const [label, expected] of [
|
||||
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
|
||||
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
|
||||
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
|
||||
] as const) {
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText(label))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
|
||||
fetchMock.mockClear()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-027: a download names the file after the trip', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(clickedHref).toBe('blob:gpx'))
|
||||
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-028: an empty trip says so instead of reporting a failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(clickedHref).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-029: a real failure toasts the error', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toast = makeToast()
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
|
||||
render(<DayPlanSidebarToolbar {...makeProps({ toast })} />)
|
||||
await user.hover(screen.getByText('GPX'))
|
||||
await user.click(screen.getByText('dayplan.gpxAll'))
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus, Route as RouteIcon } from 'lucide-react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus, MapPin, Route as RouteIcon } from 'lucide-react'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -7,6 +7,18 @@ import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
|
||||
/**
|
||||
* What a GPX download can carry. Worded by what someone wants on their device
|
||||
* rather than by the GPX element it maps to: "everything", "just the places" for
|
||||
* an offline map, "just the days" for following a plan. An omitted flag defaults
|
||||
* to true server-side, so the first entry needs no query at all.
|
||||
*/
|
||||
const GPX_SCOPES = [
|
||||
{ key: 'all', query: '', labelKey: 'dayplan.gpxAll', icon: FileDown },
|
||||
{ key: 'places', query: '?dayRoutes=false', labelKey: 'dayplan.gpxPlaces', icon: MapPin },
|
||||
{ key: 'days', query: '?waypoints=false&tracks=false', labelKey: 'dayplan.gpxDays', icon: RouteIcon },
|
||||
] as const
|
||||
|
||||
interface DayPlanSidebarToolbarProps {
|
||||
tripId: number
|
||||
trip: Trip
|
||||
@@ -54,6 +66,8 @@ export function DayPlanSidebarToolbar({
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
const icsMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [icsMenuVisible, setIcsMenuVisible] = useState(false)
|
||||
const gpxMenuTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [gpxMenuVisible, setGpxMenuVisible] = useState(false)
|
||||
|
||||
const showIcsMenu = () => {
|
||||
if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current)
|
||||
@@ -67,6 +81,32 @@ export function DayPlanSidebarToolbar({
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const showGpxMenu = () => {
|
||||
if (gpxMenuTimeoutRef.current) clearTimeout(gpxMenuTimeoutRef.current)
|
||||
setGpxMenuVisible(true)
|
||||
}
|
||||
const hideGpxMenu = () => {
|
||||
gpxMenuTimeoutRef.current = setTimeout(() => setGpxMenuVisible(false), 120)
|
||||
}
|
||||
|
||||
const downloadGpx = async (query: string) => {
|
||||
setGpxMenuVisible(false)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${tripId}/places/export.gpx${query}`, { credentials: 'include' })
|
||||
// 404 here means the selection is empty, which is worth its own message:
|
||||
// "nothing happened" and "the download broke" look identical otherwise.
|
||||
if (res.status === 404) { toast.info(t('dayplan.gpxEmpty')); return }
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${trip?.title || 'trip'}.gpx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error(t('dayplan.gpxFailed')) }
|
||||
}
|
||||
|
||||
const menuItemStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 7,
|
||||
width: '100%', padding: '7px 12px', border: 'none',
|
||||
@@ -132,7 +172,7 @@ export function DayPlanSidebarToolbar({
|
||||
onMouseEnter={showIcsMenu}
|
||||
onMouseLeave={hideIcsMenu}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', right: 0,
|
||||
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 200, minWidth: 160,
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
@@ -181,6 +221,59 @@ export function DayPlanSidebarToolbar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* GPX — the counterpart to the GPX import in the places sidebar, sitting
|
||||
with the other exports rather than with the importer. Same hover-menu
|
||||
shape as ICS beside it. */}
|
||||
<div
|
||||
style={{ position: 'relative', flexShrink: 0 }}
|
||||
onMouseEnter={showGpxMenu}
|
||||
onMouseLeave={hideGpxMenu}
|
||||
>
|
||||
<button
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={gpxMenuVisible}
|
||||
aria-label={t('dayplan.gpxTooltip')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-muted)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<FileDown size={13} strokeWidth={2} />
|
||||
GPX
|
||||
</button>
|
||||
{gpxMenuVisible && (
|
||||
<div
|
||||
role="menu"
|
||||
onMouseEnter={showGpxMenu}
|
||||
onMouseLeave={hideGpxMenu}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 200, minWidth: 190,
|
||||
background: 'var(--bg-card, white)',
|
||||
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{GPX_SCOPES.map(scope => (
|
||||
<button
|
||||
key={scope.key}
|
||||
role="menuitem"
|
||||
onClick={() => downloadGpx(scope.query)}
|
||||
style={menuItemStyle}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'var(--bg-hover, #f3f4f6)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<scope.icon size={12} strokeWidth={2} />
|
||||
{t(scope.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{subscribeOpen && canManageShare && (
|
||||
<IcsSubscribeModal
|
||||
endpoint={`/api/trips/${tripId}/feed`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { CalendarPlus, ChevronRight, FileDown } from 'lucide-react'
|
||||
import { CalendarPlus, ChevronRight, FileDown, Share2 } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeModal'
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
@@ -9,9 +9,9 @@ import type { MTripSheetsProps } from '../MTripShell'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Calendar & export sheet ('export', opened from the Mehr sheet): the desktop
|
||||
* day-plan toolbar's PDF export, ICS download and calendar subscription in one
|
||||
* place. The subscription dialog is the shared IcsSubscribeModal — it owns the
|
||||
* Export sheet ('export', opened from the Mehr sheet): the desktop day-plan
|
||||
* toolbar's PDF export, GPX download, ICS download and calendar subscription in
|
||||
* one place. The subscription dialog is the shared IcsSubscribeModal — it owns the
|
||||
* enable/rotate/disable token flow.
|
||||
*/
|
||||
export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
@@ -21,6 +21,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
const [pdfBusy, setPdfBusy] = useState(false)
|
||||
const [icsBusy, setIcsBusy] = useState(false)
|
||||
const [gpxBusy, setGpxBusy] = useState(false)
|
||||
// The subscription link reads the trip without an account, so it needs the
|
||||
// same permission as the public share link. The ICS download beside it does
|
||||
// not: that is a file this member may already read.
|
||||
@@ -77,12 +78,38 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Everything in one file here: the desktop menu's three scopes are a hover
|
||||
// affordance the phone does not have, and "the whole trip" is what you want
|
||||
// on a device anyway.
|
||||
const downloadGpx = async () => {
|
||||
if (gpxBusy) return
|
||||
setGpxBusy(true)
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${planner.tripId}/places/export.gpx`, { credentials: 'include' })
|
||||
if (res.status === 404) { planner.toast.info(t('dayplan.gpxEmpty')); return }
|
||||
if (!res.ok) throw new Error()
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${planner.trip?.title || 'trip'}.gpx`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => { URL.revokeObjectURL(url); a.remove() }, 100)
|
||||
shell.closeSheet()
|
||||
} catch {
|
||||
planner.toast.error(t('dayplan.gpxFailed'))
|
||||
} finally {
|
||||
setGpxBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={shell.closeSheet} variant="card" material="glass" ariaLabel={t('mobileTrip.exportCalendar')}>
|
||||
<MSheet open={open} onClose={shell.closeSheet} variant="card" material="glass" ariaLabel={t('mobileTrip.export')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<CalendarPlus size={19} strokeWidth={1.8} />}
|
||||
title={t('mobileTrip.exportCalendar')}
|
||||
icon={<FileDown size={19} strokeWidth={1.8} />}
|
||||
title={t('mobileTrip.export')}
|
||||
onClose={shell.closeSheet}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
@@ -102,6 +129,12 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
sub={`${planner.trip?.title || 'trip'}.ics`}
|
||||
onClick={() => void downloadIcs()}
|
||||
/>
|
||||
<ExportRow
|
||||
icon={Share2}
|
||||
title={gpxBusy ? t('common.loading') : t('dayplan.gpxAll')}
|
||||
sub={t('dayplan.gpxTooltip')}
|
||||
onClick={() => void downloadGpx()}
|
||||
/>
|
||||
{canManageShare && (
|
||||
<ExportRow
|
||||
icon={CalendarPlus}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CalendarPlus, ChevronRight, Settings, Share2 } from 'lucide-react'
|
||||
import { ChevronRight, FileDown, Settings, Share2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
@@ -46,7 +46,7 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
|
||||
|
||||
const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [
|
||||
{ id: 'members', icon: Share2, label: t('members.shareTrip'), go: () => shell.openSheet('members') },
|
||||
{ id: 'export', icon: CalendarPlus, label: t('mobileTrip.exportCalendar'), go: () => shell.openSheet('export') },
|
||||
{ id: 'export', icon: FileDown, label: t('mobileTrip.export'), go: () => shell.openSheet('export') },
|
||||
...(canEditTrip
|
||||
? [{ id: 'tripedit', icon: Settings, label: t('dashboard.editTrip'), go: () => shell.openSheet('tripedit') }]
|
||||
: []),
|
||||
|
||||
Binary file not shown.
@@ -10,11 +10,13 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { memoryStorage } from 'multer';
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
PlaceBulkDeleteDto,
|
||||
PlaceBulkUpdateDto,
|
||||
PlaceCreateDto,
|
||||
PlaceExportGpxDto,
|
||||
PlaceImportGpxDto,
|
||||
PlaceImportListDto,
|
||||
PlaceImportMapDto,
|
||||
@@ -165,6 +168,34 @@ export class PlacesController {
|
||||
return { places: result.places, count: result.count, skipped: result.skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip as GPX, for Organic Maps, a handheld or anything else that reads it.
|
||||
* A read, so viewing the trip is enough; no edit permission required. Declared
|
||||
* before the `:id` routes so the literal path wins.
|
||||
*/
|
||||
@Get('export.gpx')
|
||||
exportGpx(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Query() query: PlaceExportGpxDto,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
this.requireTrip(tripId, user);
|
||||
const waypoints = parseBool(query.waypoints, true);
|
||||
const tracks = parseBool(query.tracks, true);
|
||||
const dayRoutes = parseBool(query.dayRoutes, true);
|
||||
if (!waypoints && !tracks && !dayRoutes) {
|
||||
throw new HttpException({ error: 'No export types selected' }, 400);
|
||||
}
|
||||
const result = this.places.exportGpx(tripId, { waypoints, tracks, dayRoutes });
|
||||
if (!result) {
|
||||
throw new HttpException({ error: 'Nothing to export' }, 404);
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/gpx+xml; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
|
||||
res.send(result.gpx);
|
||||
}
|
||||
|
||||
@Post('import/map')
|
||||
@UseInterceptors(FileInterceptor('file', UPLOAD))
|
||||
async importMap(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
placeImportGpxRequestSchema,
|
||||
placeImportListRequestSchema,
|
||||
placeImportMapRequestSchema,
|
||||
placeExportGpxRequestSchema,
|
||||
placeRatingRequestSchema,
|
||||
placeUpdateRequestSchema,
|
||||
} from '@trek/shared';
|
||||
@@ -32,3 +33,4 @@ export class PlaceBulkUpdateDto extends createZodDto(placeBulkUpdateRequestSchem
|
||||
export class PlaceImportListDto extends createZodDto(placeImportListRequestSchema) {}
|
||||
export class PlaceImportGpxDto extends createZodDto(placeImportGpxRequestSchema) {}
|
||||
export class PlaceImportMapDto extends createZodDto(placeImportMapRequestSchema) {}
|
||||
export class PlaceExportGpxDto extends createZodDto(placeExportGpxRequestSchema) {}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
parsePlacemarkNode,
|
||||
resolveCategoryIdForFolder,
|
||||
} from './kml-import.helpers';
|
||||
import { buildGpx, gpxFilename } from './gpx-export.helpers';
|
||||
import type { GpxExportDay, GpxExportOptions, GpxExportPlace } from './gpx-export.helpers';
|
||||
import { UnsplashService } from '../unsplash/unsplash.service';
|
||||
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
|
||||
import { type UpdateConflict, isUpdateConflict } from '../common/conflictResult';
|
||||
@@ -471,6 +473,54 @@ export class PlacesService {
|
||||
return this.colorizeImportedTracks(tripId, this.importGpxRows(tripId, fileBuffer, opts));
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip as a GPX document, the mirror of importGpx. Places without geometry
|
||||
* become waypoints, places carrying route_geometry become tracks, and each planned
|
||||
* day becomes a route of its stops in order, which is the part that has no import
|
||||
* counterpart and the reason to bother: a planned day on a handheld.
|
||||
*
|
||||
* Returns null when the selection yields nothing, so the caller answers 404 rather
|
||||
* than handing over a file that imports as nothing on the other end.
|
||||
*/
|
||||
exportGpx(tripId: string, opts: GpxExportOptions = {}): { gpx: string; filename: string } | null {
|
||||
const trip = this.dbs.get<{ title: string }>('SELECT title FROM trips WHERE id = ?', tripId);
|
||||
if (!trip) return null;
|
||||
|
||||
const places = this.dbs.all<GpxExportPlace>(`
|
||||
SELECT p.name, p.description, p.address, p.lat, p.lng, p.route_geometry, c.name AS category
|
||||
FROM places p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
WHERE p.trip_id = ?
|
||||
ORDER BY p.id
|
||||
`, tripId);
|
||||
|
||||
// One row per stop, ordered the way the day plan draws it, then folded into days.
|
||||
const stops = this.dbs.all<{
|
||||
day_number: number; date: string | null; title: string | null;
|
||||
name: string; lat: number; lng: number;
|
||||
}>(`
|
||||
SELECT d.day_number, d.date, d.title, p.name, p.lat, p.lng
|
||||
FROM days d
|
||||
JOIN day_assignments da ON da.day_id = d.id
|
||||
JOIN places p ON p.id = da.place_id
|
||||
WHERE d.trip_id = ? AND p.lat IS NOT NULL AND p.lng IS NOT NULL
|
||||
ORDER BY d.day_number, da.order_index
|
||||
`, tripId);
|
||||
|
||||
const days = new Map<number, GpxExportDay>();
|
||||
for (const stop of stops) {
|
||||
let day = days.get(stop.day_number);
|
||||
if (!day) {
|
||||
day = { dayNumber: stop.day_number, date: stop.date, title: stop.title, points: [] };
|
||||
days.set(stop.day_number, day);
|
||||
}
|
||||
day.points.push({ name: stop.name, lat: stop.lat, lng: stop.lng });
|
||||
}
|
||||
|
||||
const gpx = buildGpx({ tripTitle: trip.title, places, days: [...days.values()] }, opts);
|
||||
return gpx ? { gpx, filename: gpxFilename(trip.title) } : null;
|
||||
}
|
||||
|
||||
private importGpxRows(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}): GpxImportResult | null {
|
||||
const { importWaypoints = true, importRoutes = true, importTracks = true, defaultName } = opts;
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ const { db } = vi.hoisted(() => {
|
||||
tmp.exec(`CREATE TABLE place_ratings (place_id INTEGER NOT NULL, user_id INTEGER NOT NULL, rating INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(place_id, user_id));`);
|
||||
// The assignment=unassigned/assigned filters join these.
|
||||
tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT);');
|
||||
tmp.exec('CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, day_number INTEGER, date TEXT, title TEXT);');
|
||||
// The GPX export reads the trip title for <metadata> and the filename.
|
||||
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
|
||||
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
|
||||
place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0);`);
|
||||
// reclaimPlaceImage ref-counts an uploaded thumbnail across both tables.
|
||||
@@ -106,7 +108,7 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.exec('DELETE FROM places; DELETE FROM place_tags; DELETE FROM place_ratings; DELETE FROM day_assignments; DELETE FROM days;');
|
||||
db.exec('DELETE FROM trips; DELETE FROM places; DELETE FROM place_tags; DELETE FROM place_ratings; DELETE FROM day_assignments; DELETE FROM days;');
|
||||
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
@@ -292,6 +294,76 @@ describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(del.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
// ── GPX export ────────────────────────────────────────────────────────────
|
||||
describe('GET export.gpx (#1442)', () => {
|
||||
const seedTrip = () => {
|
||||
db.prepare("INSERT INTO trips (id, title) VALUES (5, 'Alpine week')").run();
|
||||
db.prepare("INSERT INTO places (id, trip_id, name, lat, lng) VALUES (1, 5, 'Trailhead', 47.1, 11.2)").run();
|
||||
db.prepare("INSERT INTO places (id, trip_id, name, lat, lng, route_geometry) VALUES (2, 5, 'Ridge', 47.2, 11.3, '[[47.2,11.3],[47.25,11.35]]')").run();
|
||||
db.prepare("INSERT INTO days (id, trip_id, day_number, date, title) VALUES (1, 5, 1, '2026-05-01', 'Warm up')").run();
|
||||
db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (1, 1, 0), (1, 2, 1)').run();
|
||||
};
|
||||
|
||||
it('serves the trip as an attachment named after it', async () => {
|
||||
seedTrip();
|
||||
const res = await request(server).get('/api/trips/5/places/export.gpx').set('Cookie', sessionCookie(1));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('application/gpx+xml');
|
||||
expect(res.headers['content-disposition']).toBe('attachment; filename="Alpine-week.gpx"');
|
||||
expect(res.text).toContain('<name>Trailhead</name>');
|
||||
// Self-closing here: these points carry no elevation.
|
||||
expect(res.text).toContain('<trkpt lat="47.2" lon="11.3"');
|
||||
expect(res.text).toContain('<name>1. Warm up</name>');
|
||||
});
|
||||
|
||||
it('narrows the document to the requested parts', async () => {
|
||||
seedTrip();
|
||||
const res = await request(server)
|
||||
.get('/api/trips/5/places/export.gpx?waypoints=false&dayRoutes=false')
|
||||
.set('Cookie', sessionCookie(1));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('<trk>');
|
||||
expect(res.text).not.toContain('<wpt');
|
||||
expect(res.text).not.toContain('<rte>');
|
||||
});
|
||||
|
||||
it('400s when every part was switched off', async () => {
|
||||
seedTrip();
|
||||
const res = await request(server)
|
||||
.get('/api/trips/5/places/export.gpx?waypoints=false&tracks=false&dayRoutes=false')
|
||||
.set('Cookie', sessionCookie(1));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'No export types selected' });
|
||||
});
|
||||
|
||||
it('404s an empty trip rather than handing over a file that imports as nothing', async () => {
|
||||
db.prepare("INSERT INTO trips (id, title) VALUES (5, 'Nothing here')").run();
|
||||
const res = await request(server).get('/api/trips/5/places/export.gpx').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Nothing to export' });
|
||||
});
|
||||
|
||||
it('404s a trip the caller cannot reach, and 401s without a cookie', async () => {
|
||||
seedTrip();
|
||||
canAccessTrip.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/places/export.gpx').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
|
||||
expect((await request(server).get('/api/trips/5/places/export.gpx')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('is a read: no place_edit permission required', async () => {
|
||||
seedTrip();
|
||||
checkPermission.mockReturnValue(false);
|
||||
const res = await request(server).get('/api/trips/5/places/export.gpx').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// The reason places keeps its inline checks on the write routes: a guard runs
|
||||
// before the pipe, so guarding create would answer 404 where the suite above
|
||||
// pins a 400. This is the non-regression pin for that decision.
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* GPX writer unit tests. The builder is pure, so everything here runs without a
|
||||
* DB or a Nest container. The last block parses its own output back with the very
|
||||
* parser the importer uses, which is the closest thing to "a real reader can open
|
||||
* this" that does not need a device.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import { buildGpx, gpxFilename, type GpxExportPlace, type GpxExportDay } from '../../../src/nest/places/gpx-export.helpers';
|
||||
|
||||
const place = (over: Partial<GpxExportPlace> = {}): GpxExportPlace => ({
|
||||
name: 'Somewhere', description: null, address: null, lat: 48.8566, lng: 2.3522,
|
||||
route_geometry: null, category: null, ...over,
|
||||
});
|
||||
|
||||
const day = (over: Partial<GpxExportDay> = {}): GpxExportDay => ({
|
||||
dayNumber: 1, date: '2026-05-01', title: null, points: [], ...over,
|
||||
});
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
isArray: (name) => ['wpt', 'trkpt', 'rtept', 'trk', 'trkseg', 'rte'].includes(name),
|
||||
});
|
||||
|
||||
describe('buildGpx', () => {
|
||||
it('writes a place without geometry as a waypoint, with its category as <sym>', () => {
|
||||
const gpx = buildGpx({
|
||||
tripTitle: 'Paris',
|
||||
places: [place({ name: 'Louvre', description: 'Go early', address: 'Rue de Rivoli', category: 'Museum' })],
|
||||
days: [],
|
||||
})!;
|
||||
|
||||
expect(gpx).toContain('<wpt lat="48.8566" lon="2.3522">');
|
||||
expect(gpx).toContain('<name>Louvre</name>');
|
||||
expect(gpx).toContain('<sym>Museum</sym>');
|
||||
// Description first, address after: a device usually shows only the first line.
|
||||
expect(gpx).toContain('<desc>Go early, Rue de Rivoli</desc>');
|
||||
});
|
||||
|
||||
it('writes a place carrying geometry as a track, elevation included', () => {
|
||||
const gpx = buildGpx({
|
||||
tripTitle: 'Hike',
|
||||
places: [place({ name: 'Ridge', route_geometry: JSON.stringify([[47.1, 11.2, 1830], [47.11, 11.21, 1902]]) })],
|
||||
days: [],
|
||||
})!;
|
||||
|
||||
expect(gpx).toContain('<trkpt lat="47.1" lon="11.2">');
|
||||
expect(gpx).toContain('<ele>1830</ele>');
|
||||
expect(gpx).toContain('<ele>1902</ele>');
|
||||
});
|
||||
|
||||
it('does not also drop a waypoint on the start of a track', () => {
|
||||
const gpx = buildGpx({
|
||||
tripTitle: 'Hike',
|
||||
places: [place({ name: 'Ridge', route_geometry: JSON.stringify([[47.1, 11.2], [47.11, 11.21]]) })],
|
||||
days: [],
|
||||
})!;
|
||||
|
||||
expect(gpx).not.toContain('<wpt');
|
||||
});
|
||||
|
||||
it('writes each planned day as a route of its stops in order', () => {
|
||||
const gpx = buildGpx({
|
||||
tripTitle: 'Rome',
|
||||
places: [],
|
||||
days: [day({
|
||||
dayNumber: 2, date: '2026-05-02', title: 'Ancient city',
|
||||
points: [
|
||||
{ name: 'Colosseum', lat: 41.89, lng: 12.49 },
|
||||
{ name: 'Forum', lat: 41.892, lng: 12.485 },
|
||||
],
|
||||
})],
|
||||
})!;
|
||||
|
||||
expect(gpx).toContain('<name>2. Ancient city</name>');
|
||||
const order = [gpx.indexOf('Colosseum'), gpx.indexOf('Forum')];
|
||||
expect(order[0]).toBeLessThan(order[1]);
|
||||
});
|
||||
|
||||
it('falls back to the date, then the bare number, when a day has no title', () => {
|
||||
const dated = buildGpx({ tripTitle: 'T', places: [], days: [day({ dayNumber: 3, points: [{ name: 'A', lat: 1, lng: 1 }, { name: 'B', lat: 2, lng: 2 }] })] })!;
|
||||
expect(dated).toContain('<name>3. 2026-05-01</name>');
|
||||
|
||||
const bare = buildGpx({ tripTitle: 'T', places: [], days: [day({ dayNumber: 4, date: null, points: [{ name: 'A', lat: 1, lng: 1 }, { name: 'B', lat: 2, lng: 2 }] })] })!;
|
||||
expect(bare).toContain('<name>4</name>');
|
||||
});
|
||||
|
||||
it('skips a day with a single stop — one point is not a route', () => {
|
||||
const gpx = buildGpx({
|
||||
tripTitle: 'T',
|
||||
places: [place()],
|
||||
days: [day({ points: [{ name: 'Only stop', lat: 1, lng: 1 }] })],
|
||||
})!;
|
||||
|
||||
expect(gpx).not.toContain('<rte>');
|
||||
});
|
||||
|
||||
it('honours each flag on its own', () => {
|
||||
const input = {
|
||||
tripTitle: 'T',
|
||||
places: [place({ name: 'Point' }), place({ name: 'Line', route_geometry: JSON.stringify([[1, 1], [2, 2]]) })],
|
||||
days: [day({ points: [{ name: 'A', lat: 1, lng: 1 }, { name: 'B', lat: 2, lng: 2 }] })],
|
||||
};
|
||||
|
||||
const noWpt = buildGpx(input, { waypoints: false })!;
|
||||
expect(noWpt).not.toContain('<wpt');
|
||||
expect(noWpt).toContain('<trk>');
|
||||
|
||||
const noTrk = buildGpx(input, { tracks: false })!;
|
||||
expect(noTrk).not.toContain('<trk>');
|
||||
// With tracks off, the geometry place still has coordinates, so it lands as a waypoint.
|
||||
expect(noTrk).toContain('<name>Line</name>');
|
||||
|
||||
const noRoutes = buildGpx(input, { dayRoutes: false })!;
|
||||
expect(noRoutes).not.toContain('<rte>');
|
||||
});
|
||||
|
||||
it('returns null when the selection produced nothing', () => {
|
||||
expect(buildGpx({ tripTitle: 'Empty', places: [], days: [] })).toBeNull();
|
||||
expect(buildGpx({ tripTitle: 'T', places: [place({ lat: null, lng: null })], days: [] })).toBeNull();
|
||||
expect(buildGpx({ tripTitle: 'T', places: [place()], days: [] }, { waypoints: false })).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores geometry that is not a usable coordinate list', () => {
|
||||
const garbage = ['not json', '{}', '[]', JSON.stringify([['a', 'b']]), JSON.stringify([[1]])];
|
||||
for (const route_geometry of garbage) {
|
||||
// Falls through to the waypoint branch rather than emitting an empty <trkseg>.
|
||||
const gpx = buildGpx({ tripTitle: 'T', places: [place({ route_geometry })], days: [] })!;
|
||||
expect(gpx).not.toContain('<trk>');
|
||||
expect(gpx).toContain('<wpt');
|
||||
}
|
||||
});
|
||||
|
||||
it('produces a document the GPX parser reads back', () => {
|
||||
const gpx = buildGpx({
|
||||
tripTitle: 'Round trip',
|
||||
places: [
|
||||
place({ name: 'Waypoint', lat: 10.5, lng: -3.25 }),
|
||||
place({ name: 'Track', route_geometry: JSON.stringify([[1.5, 2.5], [1.6, 2.6]]) }),
|
||||
],
|
||||
days: [day({ points: [{ name: 'A', lat: 3, lng: 4 }, { name: 'B', lat: 5, lng: 6 }] })],
|
||||
})!;
|
||||
|
||||
expect(XMLValidator.validate(gpx)).toBe(true);
|
||||
|
||||
const parsed = parser.parse(gpx).gpx;
|
||||
expect(parsed['@_version']).toBe('1.1');
|
||||
expect(parsed.metadata.name).toBe('Round trip');
|
||||
expect(parsed.wpt).toHaveLength(1);
|
||||
// Attributes come back as strings — the importer runs them through parseFloat too.
|
||||
expect(parsed.wpt[0]['@_lat']).toBe('10.5');
|
||||
expect(parsed.trk[0].trkseg[0].trkpt).toHaveLength(2);
|
||||
expect(parsed.rte[0].rtept).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('escapes markup in names instead of breaking the document', () => {
|
||||
const gpx = buildGpx({ tripTitle: 'T', places: [place({ name: 'Bar & Grill <best>' })], days: [] })!;
|
||||
expect(XMLValidator.validate(gpx)).toBe(true);
|
||||
expect(parser.parse(gpx).gpx.wpt[0].name).toBe('Bar & Grill <best>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gpxFilename', () => {
|
||||
it('keeps a plain title readable', () => {
|
||||
expect(gpxFilename('Berlin 2026')).toBe('Berlin-2026.gpx');
|
||||
});
|
||||
|
||||
it('drops what a filesystem or a header would choke on', () => {
|
||||
expect(gpxFilename('Trip: "Rome"/2026 <draft>')).toBe('Trip-Rome-2026-draft.gpx');
|
||||
});
|
||||
|
||||
it('falls back when a title leaves nothing behind', () => {
|
||||
expect(gpxFilename('///')).toBe('trip.gpx');
|
||||
expect(gpxFilename('')).toBe('trip.gpx');
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.confirmed': 'مؤكد',
|
||||
'dayplan.pendingRes': 'قيد الانتظار',
|
||||
'dayplan.pdfTooltip': 'تصدير خطة اليوم بصيغة PDF',
|
||||
'dayplan.gpxTooltip': 'تصدير كملف GPX للخرائط دون اتصال وأجهزة GPS',
|
||||
'dayplan.gpxAll': 'الرحلة كاملة',
|
||||
'dayplan.gpxPlaces': 'الأماكن فقط',
|
||||
'dayplan.gpxDays': 'الأيام كمسارات',
|
||||
'dayplan.gpxEmpty': 'لا يوجد شيء للتصدير بعد',
|
||||
'dayplan.gpxFailed': 'فشل تصدير GPX',
|
||||
'dayplan.pdfError': 'فشل تصدير PDF',
|
||||
'dayplan.expandAll': 'Expand all days', // en-fallback
|
||||
'dayplan.collapseAll': 'Collapse all days', // en-fallback
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'هذه الميزة معطّلة لهذه الرحلة',
|
||||
'mobileTrip.compactView': 'عرض مضغوط',
|
||||
'mobileTrip.dayTitlePlaceholder': 'عنوان اليوم',
|
||||
'mobileTrip.exportCalendar': 'تصدير التقويم',
|
||||
'mobileTrip.export': 'تصدير',
|
||||
'mobileTrip.filesEmpty': 'لا توجد ملفات بعد',
|
||||
'mobileTrip.filesNext': 'الملف التالي',
|
||||
'mobileTrip.filesPrev': 'الملف السابق',
|
||||
|
||||
@@ -51,6 +51,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Pendente',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exportar plano do dia em PDF',
|
||||
'dayplan.gpxTooltip': 'Exportar como GPX para mapas offline e aparelhos de GPS',
|
||||
'dayplan.gpxAll': 'Viagem inteira',
|
||||
'dayplan.gpxPlaces': 'Somente lugares',
|
||||
'dayplan.gpxDays': 'Dias como rotas',
|
||||
'dayplan.gpxEmpty': 'Ainda não há nada para exportar',
|
||||
'dayplan.gpxFailed': 'Falha ao exportar o GPX',
|
||||
'dayplan.pdfError': 'Falha ao exportar PDF',
|
||||
'dayplan.cannotReorderTransport': 'Reservas com horário fixo não podem ser reordenadas',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Remover horário?',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Esse recurso está desativado para esta viagem',
|
||||
'mobileTrip.compactView': 'Visualização compacta',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Título do dia',
|
||||
'mobileTrip.exportCalendar': 'Exportar calendário',
|
||||
'mobileTrip.export': 'Exportar',
|
||||
'mobileTrip.filesEmpty': 'Nenhum arquivo ainda',
|
||||
'mobileTrip.filesNext': 'Próximo arquivo',
|
||||
'mobileTrip.filesPrev': 'Arquivo anterior',
|
||||
|
||||
@@ -51,6 +51,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Pendent',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exporta el pla diari com a PDF',
|
||||
'dayplan.gpxTooltip': 'Exporta com a GPX per a mapes fora de línia i dispositius GPS',
|
||||
'dayplan.gpxAll': 'Tot el viatge',
|
||||
'dayplan.gpxPlaces': 'Només els llocs',
|
||||
'dayplan.gpxDays': 'Dies com a rutes',
|
||||
'dayplan.gpxEmpty': 'Encara no hi ha res per exportar',
|
||||
'dayplan.gpxFailed': 'L\'exportació GPX ha fallat',
|
||||
'dayplan.pdfError': "No s'ha pogut exportar el PDF",
|
||||
'dayplan.cannotReorderTransport': 'Les reserves amb hora fixa no es poden reordenar',
|
||||
'dayplan.confirmRemoveTimeTitle': "Vols eliminar l'hora?",
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Aquesta funció està desactivada per a aquest viatge',
|
||||
'mobileTrip.compactView': 'Vista compacta',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Títol del dia',
|
||||
'mobileTrip.exportCalendar': 'Exporta el calendari',
|
||||
'mobileTrip.export': 'Exporta',
|
||||
'mobileTrip.filesEmpty': 'Encara no hi ha fitxers',
|
||||
'mobileTrip.filesNext': 'Fitxer següent',
|
||||
'mobileTrip.filesPrev': 'Fitxer anterior',
|
||||
|
||||
@@ -51,6 +51,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Čeká na potvrzení',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exportovat denní plán do PDF',
|
||||
'dayplan.gpxTooltip': 'Exportovat jako GPX pro offline mapy a GPS zařízení',
|
||||
'dayplan.gpxAll': 'Celá cesta',
|
||||
'dayplan.gpxPlaces': 'Jen místa',
|
||||
'dayplan.gpxDays': 'Dny jako trasy',
|
||||
'dayplan.gpxEmpty': 'Zatím není co exportovat',
|
||||
'dayplan.gpxFailed': 'Export GPX selhal',
|
||||
'dayplan.pdfError': 'Export do PDF se nezdařil',
|
||||
'dayplan.cannotReorderTransport': 'Rezervace s pevným časem nelze přeuspořádat',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Odebrat čas?',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Tato funkce je pro tento výlet vypnuta',
|
||||
'mobileTrip.compactView': 'Kompaktní zobrazení',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Název dne',
|
||||
'mobileTrip.exportCalendar': 'Export kalendáře',
|
||||
'mobileTrip.export': 'Exportovat',
|
||||
'mobileTrip.filesEmpty': 'Zatím žádné soubory',
|
||||
'mobileTrip.filesNext': 'Další soubor',
|
||||
'mobileTrip.filesPrev': 'Předchozí soubor',
|
||||
|
||||
@@ -62,6 +62,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Ausstehend',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Tagesplan als PDF exportieren',
|
||||
'dayplan.gpxTooltip': 'Als GPX für Offline-Karten und GPS-Geräte exportieren',
|
||||
'dayplan.gpxAll': 'Ganze Reise',
|
||||
'dayplan.gpxPlaces': 'Nur Orte',
|
||||
'dayplan.gpxDays': 'Tage als Routen',
|
||||
'dayplan.gpxEmpty': 'Es gibt noch nichts zu exportieren',
|
||||
'dayplan.gpxFailed': 'GPX-Export fehlgeschlagen',
|
||||
'dayplan.pdfError': 'Fehler beim PDF-Export',
|
||||
'dayplan.mobile.addPlace': 'Ort hinzufügen',
|
||||
'dayplan.mobile.searchPlaces': 'Orte suchen...',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Diese Funktion ist für diese Reise deaktiviert',
|
||||
'mobileTrip.compactView': 'Kompakte Ansicht',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Tagestitel',
|
||||
'mobileTrip.exportCalendar': 'Kalender exportieren',
|
||||
'mobileTrip.export': 'Exportieren',
|
||||
'mobileTrip.filesEmpty': 'Keine Dateien vorhanden',
|
||||
'mobileTrip.filesNext': 'Nächste Datei',
|
||||
'mobileTrip.filesPrev': 'Vorherige Datei',
|
||||
|
||||
@@ -62,6 +62,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Pending',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Export day plan as PDF',
|
||||
'dayplan.gpxTooltip': 'Export as GPX for offline maps and GPS devices',
|
||||
'dayplan.gpxAll': 'Whole trip',
|
||||
'dayplan.gpxPlaces': 'Places only',
|
||||
'dayplan.gpxDays': 'Days as routes',
|
||||
'dayplan.gpxEmpty': 'There is nothing to export yet',
|
||||
'dayplan.gpxFailed': 'GPX export failed',
|
||||
'dayplan.pdfError': 'Failed to export PDF',
|
||||
'dayplan.mobile.addPlace': 'Add Place',
|
||||
'dayplan.mobile.searchPlaces': 'Search places...',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'This feature is disabled for this trip',
|
||||
'mobileTrip.compactView': 'Compact view',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Day title',
|
||||
'mobileTrip.exportCalendar': 'Export Calendar',
|
||||
'mobileTrip.export': 'Export',
|
||||
'mobileTrip.filesEmpty': 'No files yet',
|
||||
'mobileTrip.filesNext': 'Next file',
|
||||
'mobileTrip.filesPrev': 'Previous file',
|
||||
|
||||
@@ -51,6 +51,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Pendiente',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exportar plan diario como PDF',
|
||||
'dayplan.gpxTooltip': 'Exportar como GPX para mapas sin conexión y dispositivos GPS',
|
||||
'dayplan.gpxAll': 'Todo el viaje',
|
||||
'dayplan.gpxPlaces': 'Solo lugares',
|
||||
'dayplan.gpxDays': 'Días como rutas',
|
||||
'dayplan.gpxEmpty': 'Aún no hay nada que exportar',
|
||||
'dayplan.gpxFailed': 'Error al exportar el GPX',
|
||||
'dayplan.pdfError': 'No se pudo exportar el PDF',
|
||||
'dayplan.cannotReorderTransport': 'Las reservas con hora fija no se pueden reordenar',
|
||||
'dayplan.confirmRemoveTimeTitle': '¿Eliminar hora?',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Esta función está desactivada para este viaje',
|
||||
'mobileTrip.compactView': 'Vista compacta',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Título del día',
|
||||
'mobileTrip.exportCalendar': 'Exportar calendario',
|
||||
'mobileTrip.export': 'Exportar',
|
||||
'mobileTrip.filesEmpty': 'Aún no hay archivos',
|
||||
'mobileTrip.filesNext': 'Archivo siguiente',
|
||||
'mobileTrip.filesPrev': 'Archivo anterior',
|
||||
|
||||
@@ -51,6 +51,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'En attente',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exporter le plan du jour en PDF',
|
||||
'dayplan.gpxTooltip': 'Exporter en GPX pour les cartes hors ligne et les GPS',
|
||||
'dayplan.gpxAll': 'Tout le voyage',
|
||||
'dayplan.gpxPlaces': 'Lieux uniquement',
|
||||
'dayplan.gpxDays': 'Journées en itinéraires',
|
||||
'dayplan.gpxEmpty': 'Il n\'y a encore rien à exporter',
|
||||
'dayplan.gpxFailed': 'Échec de l\'export GPX',
|
||||
'dayplan.pdfError': "Échec de l'export PDF",
|
||||
'dayplan.cannotReorderTransport': 'Les réservations avec une heure fixe ne peuvent pas être réorganisées',
|
||||
'dayplan.confirmRemoveTimeTitle': "Supprimer l'heure ?",
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Cette fonctionnalité est désactivée pour ce voyage',
|
||||
'mobileTrip.compactView': 'Vue compacte',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Titre du jour',
|
||||
'mobileTrip.exportCalendar': 'Exporter le calendrier',
|
||||
'mobileTrip.export': 'Exporter',
|
||||
'mobileTrip.filesEmpty': 'Aucun fichier',
|
||||
'mobileTrip.filesNext': 'Fichier suivant',
|
||||
'mobileTrip.filesPrev': 'Fichier précédent',
|
||||
|
||||
@@ -62,6 +62,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Εκκρεμές',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Εξαγωγή ημερήσιου πλάνου ως PDF',
|
||||
'dayplan.gpxTooltip': 'Εξαγωγή ως GPX για χάρτες εκτός σύνδεσης και συσκευές GPS',
|
||||
'dayplan.gpxAll': 'Όλο το ταξίδι',
|
||||
'dayplan.gpxPlaces': 'Μόνο τα μέρη',
|
||||
'dayplan.gpxDays': 'Ημέρες ως διαδρομές',
|
||||
'dayplan.gpxEmpty': 'Δεν υπάρχει ακόμη κάτι για εξαγωγή',
|
||||
'dayplan.gpxFailed': 'Η εξαγωγή GPX απέτυχε',
|
||||
'dayplan.pdfError': 'Αποτυχία εξαγωγής PDF',
|
||||
'dayplan.mobile.addPlace': 'Προσθήκη Τοποθεσίας',
|
||||
'dayplan.mobile.searchPlaces': 'Αναζήτηση τοποθεσιών...',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Αυτή η λειτουργία είναι απενεργοποιημένη για αυτό το ταξίδι',
|
||||
'mobileTrip.compactView': 'Συμπαγής προβολή',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Τίτλος ημέρας',
|
||||
'mobileTrip.exportCalendar': 'Εξαγωγή Ημερολογίου',
|
||||
'mobileTrip.export': 'Εξαγωγή',
|
||||
'mobileTrip.filesEmpty': 'Δεν υπάρχουν αρχεία ακόμη',
|
||||
'mobileTrip.filesNext': 'Επόμενο αρχείο',
|
||||
'mobileTrip.filesPrev': 'Προηγούμενο αρχείο',
|
||||
|
||||
@@ -51,6 +51,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Függőben',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Napi terv exportálása PDF-be',
|
||||
'dayplan.gpxTooltip': 'Exportálás GPX-ként offline térképekhez és GPS-eszközökhöz',
|
||||
'dayplan.gpxAll': 'A teljes utazás',
|
||||
'dayplan.gpxPlaces': 'Csak a helyek',
|
||||
'dayplan.gpxDays': 'Napok útvonalként',
|
||||
'dayplan.gpxEmpty': 'Még nincs mit exportálni',
|
||||
'dayplan.gpxFailed': 'A GPX-exportálás nem sikerült',
|
||||
'dayplan.pdfError': 'Nem sikerült a PDF exportálás',
|
||||
'dayplan.cannotReorderTransport': 'A rögzített időpontú foglalások nem rendezhetők át',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Időpont eltávolítása?',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Ez a funkció ki van kapcsolva ennél az utazásnál',
|
||||
'mobileTrip.compactView': 'Kompakt nézet',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Nap címe',
|
||||
'mobileTrip.exportCalendar': 'Naptár exportálása',
|
||||
'mobileTrip.export': 'Exportálás',
|
||||
'mobileTrip.filesEmpty': 'Még nincsenek fájlok',
|
||||
'mobileTrip.filesNext': 'Következő fájl',
|
||||
'mobileTrip.filesPrev': 'Előző fájl',
|
||||
|
||||
@@ -60,6 +60,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Menunggu',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Ekspor rencana hari sebagai PDF',
|
||||
'dayplan.gpxTooltip': 'Ekspor sebagai GPX untuk peta offline dan perangkat GPS',
|
||||
'dayplan.gpxAll': 'Seluruh perjalanan',
|
||||
'dayplan.gpxPlaces': 'Hanya tempat',
|
||||
'dayplan.gpxDays': 'Hari sebagai rute',
|
||||
'dayplan.gpxEmpty': 'Belum ada yang bisa diekspor',
|
||||
'dayplan.gpxFailed': 'Ekspor GPX gagal',
|
||||
'dayplan.pdfError': 'Gagal mengekspor PDF',
|
||||
'dayplan.mobile.addPlace': 'Tambah Tempat',
|
||||
'dayplan.mobile.searchPlaces': 'Cari tempat...',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Fitur ini dinonaktifkan untuk perjalanan ini',
|
||||
'mobileTrip.compactView': 'Tampilan ringkas',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Judul hari',
|
||||
'mobileTrip.exportCalendar': 'Ekspor Kalender',
|
||||
'mobileTrip.export': 'Ekspor',
|
||||
'mobileTrip.filesEmpty': 'Belum ada file',
|
||||
'mobileTrip.filesNext': 'File berikutnya',
|
||||
'mobileTrip.filesPrev': 'File sebelumnya',
|
||||
|
||||
@@ -51,6 +51,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'In attesa',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Esporta il programma del giorno come PDF',
|
||||
'dayplan.gpxTooltip': 'Esporta in GPX per mappe offline e dispositivi GPS',
|
||||
'dayplan.gpxAll': 'Tutto il viaggio',
|
||||
'dayplan.gpxPlaces': 'Solo i luoghi',
|
||||
'dayplan.gpxDays': 'Giorni come itinerari',
|
||||
'dayplan.gpxEmpty': 'Non c\'è ancora nulla da esportare',
|
||||
'dayplan.gpxFailed': 'Esportazione GPX non riuscita',
|
||||
'dayplan.pdfError': 'Impossibile esportare il PDF',
|
||||
'dayplan.cannotReorderTransport': 'Le prenotazioni con un orario fisso non possono essere riordinate',
|
||||
'dayplan.confirmRemoveTimeTitle': "Rimuovere l'orario?",
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Questa funzione è disattivata per questo viaggio',
|
||||
'mobileTrip.compactView': 'Vista compatta',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Titolo del giorno',
|
||||
'mobileTrip.exportCalendar': 'Esporta calendario',
|
||||
'mobileTrip.export': 'Esporta',
|
||||
'mobileTrip.filesEmpty': 'Ancora nessun file',
|
||||
'mobileTrip.filesNext': 'File successivo',
|
||||
'mobileTrip.filesPrev': 'File precedente',
|
||||
|
||||
@@ -62,6 +62,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': '保留',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': '日別計画をPDFで書き出し',
|
||||
'dayplan.gpxTooltip': 'オフライン地図やGPS機器向けにGPXで書き出す',
|
||||
'dayplan.gpxAll': '旅行全体',
|
||||
'dayplan.gpxPlaces': '場所のみ',
|
||||
'dayplan.gpxDays': '日程をルートとして',
|
||||
'dayplan.gpxEmpty': 'まだ書き出せるものがありません',
|
||||
'dayplan.gpxFailed': 'GPXの書き出しに失敗しました',
|
||||
'dayplan.pdfError': 'PDFの書き出しに失敗しました',
|
||||
'dayplan.mobile.addPlace': '場所を追加',
|
||||
'dayplan.mobile.searchPlaces': '場所を検索…',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'この機能はこの旅行では無効になっています',
|
||||
'mobileTrip.compactView': 'コンパクト表示',
|
||||
'mobileTrip.dayTitlePlaceholder': '日のタイトル',
|
||||
'mobileTrip.exportCalendar': 'カレンダーをエクスポート',
|
||||
'mobileTrip.export': '書き出し',
|
||||
'mobileTrip.filesEmpty': 'ファイルはまだありません',
|
||||
'mobileTrip.filesNext': '次のファイル',
|
||||
'mobileTrip.filesPrev': '前のファイル',
|
||||
|
||||
@@ -62,6 +62,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': '대기 중',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': '일별 계획을 PDF로 내보내기',
|
||||
'dayplan.gpxTooltip': '오프라인 지도와 GPS 기기용 GPX로 내보내기',
|
||||
'dayplan.gpxAll': '여행 전체',
|
||||
'dayplan.gpxPlaces': '장소만',
|
||||
'dayplan.gpxDays': '날짜를 경로로',
|
||||
'dayplan.gpxEmpty': '아직 내보낼 것이 없습니다',
|
||||
'dayplan.gpxFailed': 'GPX 내보내기에 실패했습니다',
|
||||
'dayplan.pdfError': 'PDF 내보내기 실패',
|
||||
'dayplan.mobile.addPlace': '장소 추가',
|
||||
'dayplan.mobile.searchPlaces': '장소 검색...',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': '이 여행에서는 이 기능이 비활성화되어 있습니다',
|
||||
'mobileTrip.compactView': '간략히 보기',
|
||||
'mobileTrip.dayTitlePlaceholder': '일차 제목',
|
||||
'mobileTrip.exportCalendar': '캘린더 내보내기',
|
||||
'mobileTrip.export': '내보내기',
|
||||
'mobileTrip.filesEmpty': '아직 파일이 없습니다',
|
||||
'mobileTrip.filesNext': '다음 파일',
|
||||
'mobileTrip.filesPrev': '이전 파일',
|
||||
|
||||
@@ -51,6 +51,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'In behandeling',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Dagplan exporteren als PDF',
|
||||
'dayplan.gpxTooltip': 'Exporteren als GPX voor offline kaarten en gps-apparaten',
|
||||
'dayplan.gpxAll': 'Hele reis',
|
||||
'dayplan.gpxPlaces': 'Alleen plekken',
|
||||
'dayplan.gpxDays': 'Dagen als routes',
|
||||
'dayplan.gpxEmpty': 'Er valt nog niets te exporteren',
|
||||
'dayplan.gpxFailed': 'GPX-export mislukt',
|
||||
'dayplan.pdfError': 'PDF-export mislukt',
|
||||
'dayplan.cannotReorderTransport': 'Boekingen met een vast tijdstip kunnen niet worden verplaatst',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Tijd verwijderen?',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Deze functie is uitgeschakeld voor deze reis',
|
||||
'mobileTrip.compactView': 'Compacte weergave',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Titel van de dag',
|
||||
'mobileTrip.exportCalendar': 'Kalender exporteren',
|
||||
'mobileTrip.export': 'Exporteren',
|
||||
'mobileTrip.filesEmpty': 'Nog geen bestanden',
|
||||
'mobileTrip.filesNext': 'Volgend bestand',
|
||||
'mobileTrip.filesPrev': 'Vorig bestand',
|
||||
|
||||
@@ -61,6 +61,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Oczekujące',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Eksportuj plan dnia jako PDF',
|
||||
'dayplan.gpxTooltip': 'Eksportuj jako GPX do map offline i urządzeń GPS',
|
||||
'dayplan.gpxAll': 'Cała podróż',
|
||||
'dayplan.gpxPlaces': 'Tylko miejsca',
|
||||
'dayplan.gpxDays': 'Dni jako trasy',
|
||||
'dayplan.gpxEmpty': 'Nie ma jeszcze czego eksportować',
|
||||
'dayplan.gpxFailed': 'Eksport GPX nie powiódł się',
|
||||
'dayplan.pdfError': 'Nie udało się wyeksportować pliku PDF',
|
||||
'dayplan.mobile.addPlace': 'Dodaj miejsce',
|
||||
'dayplan.mobile.searchPlaces': 'Szukaj miejsc...',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Ta funkcja jest wyłączona dla tej podróży',
|
||||
'mobileTrip.compactView': 'Widok kompaktowy',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Tytuł dnia',
|
||||
'mobileTrip.exportCalendar': 'Eksportuj kalendarz',
|
||||
'mobileTrip.export': 'Eksportuj',
|
||||
'mobileTrip.filesEmpty': 'Brak plików',
|
||||
'mobileTrip.filesNext': 'Następny plik',
|
||||
'mobileTrip.filesPrev': 'Poprzedni plik',
|
||||
|
||||
@@ -51,6 +51,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Ожидание',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Экспортировать план дня в PDF',
|
||||
'dayplan.gpxTooltip': 'Экспорт в GPX для офлайн-карт и GPS-устройств',
|
||||
'dayplan.gpxAll': 'Вся поездка',
|
||||
'dayplan.gpxPlaces': 'Только места',
|
||||
'dayplan.gpxDays': 'Дни как маршруты',
|
||||
'dayplan.gpxEmpty': 'Пока нечего экспортировать',
|
||||
'dayplan.gpxFailed': 'Не удалось экспортировать GPX',
|
||||
'dayplan.pdfError': 'Ошибка экспорта PDF',
|
||||
'dayplan.cannotReorderTransport': 'Бронирования с фиксированным временем нельзя перемещать',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Удалить время?',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Эта функция отключена для этой поездки',
|
||||
'mobileTrip.compactView': 'Компактный вид',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Название дня',
|
||||
'mobileTrip.exportCalendar': 'Экспорт календаря',
|
||||
'mobileTrip.export': 'Экспорт',
|
||||
'mobileTrip.filesEmpty': 'Файлов пока нет',
|
||||
'mobileTrip.filesNext': 'Следующий файл',
|
||||
'mobileTrip.filesPrev': 'Предыдущий файл',
|
||||
|
||||
@@ -63,6 +63,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Pendlande',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Exportera dagsplanen som PDF',
|
||||
'dayplan.gpxTooltip': 'Exportera som GPX för offlinekartor och GPS-enheter',
|
||||
'dayplan.gpxAll': 'Hela resan',
|
||||
'dayplan.gpxPlaces': 'Bara platser',
|
||||
'dayplan.gpxDays': 'Dagar som rutter',
|
||||
'dayplan.gpxEmpty': 'Det finns inget att exportera än',
|
||||
'dayplan.gpxFailed': 'GPX-exporten misslyckades',
|
||||
'dayplan.pdfError': 'Kunde inte exportera PDF',
|
||||
'dayplan.mobile.addPlace': 'Lägg itll plats',
|
||||
'dayplan.mobile.searchPlaces': 'Sök platser...',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Den här funktionen är inaktiverad för den här resan',
|
||||
'mobileTrip.compactView': 'Kompakt vy',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Dagens titel',
|
||||
'mobileTrip.exportCalendar': 'Exportera kalender',
|
||||
'mobileTrip.export': 'Exportera',
|
||||
'mobileTrip.filesEmpty': 'Inga filer ännu',
|
||||
'mobileTrip.filesNext': 'Nästa fil',
|
||||
'mobileTrip.filesPrev': 'Föregående fil',
|
||||
|
||||
@@ -62,6 +62,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Beklemede',
|
||||
'dayplan.pdf': "PDF'ler",
|
||||
'dayplan.pdfTooltip': 'Gün planını PDF olarak dışa aktar',
|
||||
'dayplan.gpxTooltip': 'Çevrimdışı haritalar ve GPS cihazları için GPX olarak dışa aktar',
|
||||
'dayplan.gpxAll': 'Tüm gezi',
|
||||
'dayplan.gpxPlaces': 'Yalnızca yerler',
|
||||
'dayplan.gpxDays': 'Günler rota olarak',
|
||||
'dayplan.gpxEmpty': 'Henüz dışa aktarılacak bir şey yok',
|
||||
'dayplan.gpxFailed': 'GPX dışa aktarma başarısız oldu',
|
||||
'dayplan.pdfError': 'PDF dışa aktarılamadı',
|
||||
'dayplan.mobile.addPlace': 'Yer Ekle',
|
||||
'dayplan.mobile.searchPlaces': 'Yer ara...',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Bu özellik bu seyahat için devre dışı',
|
||||
'mobileTrip.compactView': 'Kompakt görünüm',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Gün başlığı',
|
||||
'mobileTrip.exportCalendar': 'Takvimi Dışa Aktar',
|
||||
'mobileTrip.export': 'Dışa aktar',
|
||||
'mobileTrip.filesEmpty': 'Henüz dosya yok',
|
||||
'mobileTrip.filesNext': 'Sonraki dosya',
|
||||
'mobileTrip.filesPrev': 'Önceki dosya',
|
||||
|
||||
@@ -53,6 +53,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Ожидание',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Експортувати план дня в PDF',
|
||||
'dayplan.gpxTooltip': 'Експорт у GPX для офлайн-карт і GPS-пристроїв',
|
||||
'dayplan.gpxAll': 'Уся подорож',
|
||||
'dayplan.gpxPlaces': 'Лише місця',
|
||||
'dayplan.gpxDays': 'Дні як маршрути',
|
||||
'dayplan.gpxEmpty': 'Поки немає чого експортувати',
|
||||
'dayplan.gpxFailed': 'Не вдалося експортувати GPX',
|
||||
'dayplan.pdfError': 'Помилка експорту PDF',
|
||||
'dayplan.cannotReorderTransport': 'Бронювання з фіксованим часом не можна переміщувати',
|
||||
'dayplan.confirmRemoveTimeTitle': 'Видалити час?',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Цю функцію вимкнено для цієї подорожі',
|
||||
'mobileTrip.compactView': 'Компактний вигляд',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Назва дня',
|
||||
'mobileTrip.exportCalendar': 'Експорт календаря',
|
||||
'mobileTrip.export': 'Експорт',
|
||||
'mobileTrip.filesEmpty': 'Файлів поки немає',
|
||||
'mobileTrip.filesNext': 'Наступний файл',
|
||||
'mobileTrip.filesPrev': 'Попередній файл',
|
||||
|
||||
@@ -62,6 +62,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': 'Chưa giải quyết',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': 'Kế hoạch ngày xuất dưới dạng PDF',
|
||||
'dayplan.gpxTooltip': 'Xuất ra GPX cho bản đồ ngoại tuyến và thiết bị GPS',
|
||||
'dayplan.gpxAll': 'Toàn bộ chuyến đi',
|
||||
'dayplan.gpxPlaces': 'Chỉ địa điểm',
|
||||
'dayplan.gpxDays': 'Các ngày dưới dạng lộ trình',
|
||||
'dayplan.gpxEmpty': 'Chưa có gì để xuất',
|
||||
'dayplan.gpxFailed': 'Xuất GPX thất bại',
|
||||
'dayplan.pdfError': 'Không xuất được PDF',
|
||||
'dayplan.mobile.addPlace': 'Thêm địa điểm',
|
||||
'dayplan.mobile.searchPlaces': 'Tìm kiếm địa điểm...',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': 'Tính năng này bị tắt cho chuyến đi này',
|
||||
'mobileTrip.compactView': 'Chế độ xem gọn',
|
||||
'mobileTrip.dayTitlePlaceholder': 'Tiêu đề ngày',
|
||||
'mobileTrip.exportCalendar': 'Xuất lịch',
|
||||
'mobileTrip.export': 'Xuất',
|
||||
'mobileTrip.filesEmpty': 'Chưa có tập tin nào',
|
||||
'mobileTrip.filesNext': 'Tập tin tiếp theo',
|
||||
'mobileTrip.filesPrev': 'Tập tin trước',
|
||||
|
||||
@@ -51,6 +51,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': '待確認',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': '匯出當天計劃為 PDF',
|
||||
'dayplan.gpxTooltip': '匯出為 GPX,用於離線地圖和 GPS 裝置',
|
||||
'dayplan.gpxAll': '整趟行程',
|
||||
'dayplan.gpxPlaces': '僅地點',
|
||||
'dayplan.gpxDays': '按天產生路線',
|
||||
'dayplan.gpxEmpty': '暫時沒有可匯出的內容',
|
||||
'dayplan.gpxFailed': 'GPX 匯出失敗',
|
||||
'dayplan.pdfError': 'PDF 匯出失敗',
|
||||
'dayplan.cannotReorderTransport': '有固定時間的預訂無法重新排序',
|
||||
'dayplan.confirmRemoveTimeTitle': '移除時間?',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': '此功能已針對本次行程停用',
|
||||
'mobileTrip.compactView': '精簡檢視',
|
||||
'mobileTrip.dayTitlePlaceholder': '天數標題',
|
||||
'mobileTrip.exportCalendar': '匯出行事曆',
|
||||
'mobileTrip.export': '匯出',
|
||||
'mobileTrip.filesEmpty': '暫無檔案',
|
||||
'mobileTrip.filesNext': '下一個檔案',
|
||||
'mobileTrip.filesPrev': '上一個檔案',
|
||||
|
||||
@@ -51,6 +51,12 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.pendingRes': '待确认',
|
||||
'dayplan.pdf': 'PDF',
|
||||
'dayplan.pdfTooltip': '导出当天计划为 PDF',
|
||||
'dayplan.gpxTooltip': '导出为 GPX,用于离线地图和 GPS 设备',
|
||||
'dayplan.gpxAll': '整趟行程',
|
||||
'dayplan.gpxPlaces': '仅地点',
|
||||
'dayplan.gpxDays': '按天生成路线',
|
||||
'dayplan.gpxEmpty': '暂时没有可导出的内容',
|
||||
'dayplan.gpxFailed': 'GPX 导出失败',
|
||||
'dayplan.pdfError': 'PDF 导出失败',
|
||||
'dayplan.cannotReorderTransport': '有固定时间的预订无法重新排序',
|
||||
'dayplan.confirmRemoveTimeTitle': '移除时间?',
|
||||
|
||||
@@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = {
|
||||
'mobileTrip.collabFeatureDisabled': '此功能已针对本次行程停用',
|
||||
'mobileTrip.compactView': '紧凑视图',
|
||||
'mobileTrip.dayTitlePlaceholder': '天数标题',
|
||||
'mobileTrip.exportCalendar': '导出日历',
|
||||
'mobileTrip.export': '导出',
|
||||
'mobileTrip.filesEmpty': '暂无文件',
|
||||
'mobileTrip.filesNext': '下一个文件',
|
||||
'mobileTrip.filesPrev': '上一个文件',
|
||||
|
||||
@@ -174,6 +174,18 @@ export const placeImportGpxRequestSchema = z.object({
|
||||
});
|
||||
export type PlaceImportGpxRequest = z.infer<typeof placeImportGpxRequestSchema>;
|
||||
|
||||
/**
|
||||
* GPX export (query string), the mirror of the import flags. Every field optional
|
||||
* and string-typed because these arrive as query parameters; an omitted flag
|
||||
* defaults to true server-side, so a bare `export.gpx` returns everything.
|
||||
*/
|
||||
export const placeExportGpxRequestSchema = z.object({
|
||||
waypoints: z.string().optional(),
|
||||
tracks: z.string().optional(),
|
||||
dayRoutes: z.string().optional(),
|
||||
});
|
||||
export type PlaceExportGpxRequest = z.infer<typeof placeExportGpxRequestSchema>;
|
||||
|
||||
/** KML/KMZ import (multipart/form-data); same string-field contract as GPX. */
|
||||
export const placeImportMapRequestSchema = z.object({
|
||||
importPoints: z.string().optional(),
|
||||
|
||||
@@ -40,6 +40,18 @@ Any track that carries a colour — assigned at import or picked by you — is d
|
||||
|
||||
Clicking a line on the map selects that track and opens its details — useful when the start markers are still clustered together. In the places list, each track shows a short stroke in the colour it is drawn in, which is how you tell which line belongs to which entry.
|
||||
|
||||
### Exporting a trip as GPX
|
||||
|
||||
The **GPX** button in the day sidebar's toolbar, next to PDF and ICS, hands the trip back as a `.gpx` file for offline maps such as Organic Maps, for a handheld GPS, or for any other tool that reads the format. On a phone the same download sits in the trip's **Export** sheet, under "More".
|
||||
|
||||
Three scopes:
|
||||
|
||||
- **Whole trip** — every place as a waypoint, every imported track as a track, and every planned day as a route.
|
||||
- **Places only** — the same without the day routes, for when you just want the pins on an offline map.
|
||||
- **Days as routes** — only the planned days, each one a route through its stops in the order you arranged them. This is the one that puts a day's plan on a device you can follow.
|
||||
|
||||
Places carry their description and address, and their category travels along as the GPX symbol, so devices that support it can show a different icon per kind of stop. Elevation is written back for tracks that were imported with it. Exporting is a read, so every trip member can do it, not only those who may edit.
|
||||
|
||||
## Route time pills
|
||||
|
||||
At zoom level 12 or higher, small pill-shaped labels appear between consecutive places and show the estimated **walking time** and **driving time** for each segment. Below zoom 12 they are hidden to keep the map clean.
|
||||
|
||||
Reference in New Issue
Block a user