diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx index 1cf4f43a9..b4a56f049 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.test.tsx @@ -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(['']) }) as unknown as Response + + it('FE-PLANNER-DPTOOLBAR-025: the menu offers the three scopes on hover', async () => { + const user = userEvent.setup() + render() + 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() + + 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() + 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() + 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() + await user.hover(screen.getByText('GPX')) + await user.click(screen.getByText('dayplan.gpxAll')) + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed')) + }) + }) }) diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.tsx index 2d3c0515d..d37cf3fc9 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.tsx @@ -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 | null>(null) const [icsMenuVisible, setIcsMenuVisible] = useState(false) + const gpxMenuTimeoutRef = useRef | 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({ )} + {/* 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. */} +
+ + {gpxMenuVisible && ( +
+ {GPX_SCOPES.map(scope => ( + + ))} +
+ )} +
{subscribeOpen && canManageShare && ( { + 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 ( - +
} - title={t('mobileTrip.exportCalendar')} + icon={} + 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()} /> + void downloadGpx()} + /> {canManageShare && ( 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') }] : []), diff --git a/server/src/nest/places/gpx-export.helpers.ts b/server/src/nest/places/gpx-export.helpers.ts new file mode 100644 index 000000000..41b878876 Binary files /dev/null and b/server/src/nest/places/gpx-export.helpers.ts differ diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index b0c2183fe..23ccf7f8e 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -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( diff --git a/server/src/nest/places/places.dto.ts b/server/src/nest/places/places.dto.ts index d6b35fce4..3f78b7aef 100644 --- a/server/src/nest/places/places.dto.ts +++ b/server/src/nest/places/places.dto.ts @@ -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) {} diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 113bd49b2..141a5e93f 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -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(` + 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(); + 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; diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index 0978166f9..a85bb26ea 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -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 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('Trailhead'); + // Self-closing here: these points carry no elevation. + expect(res.text).toContain('1. Warm up'); + }); + + 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(''); + expect(res.text).not.toContain(''); + }); + + 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. diff --git a/server/tests/unit/nest/gpx-export.helpers.test.ts b/server/tests/unit/nest/gpx-export.helpers.test.ts new file mode 100644 index 000000000..5becba73c --- /dev/null +++ b/server/tests/unit/nest/gpx-export.helpers.test.ts @@ -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 => ({ + name: 'Somewhere', description: null, address: null, lat: 48.8566, lng: 2.3522, + route_geometry: null, category: null, ...over, +}); + +const day = (over: Partial = {}): 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 ', () => { + const gpx = buildGpx({ + tripTitle: 'Paris', + places: [place({ name: 'Louvre', description: 'Go early', address: 'Rue de Rivoli', category: 'Museum' })], + days: [], + })!; + + expect(gpx).toContain(''); + expect(gpx).toContain('Louvre'); + expect(gpx).toContain('Museum'); + // Description first, address after: a device usually shows only the first line. + expect(gpx).toContain('Go early, Rue de Rivoli'); + }); + + 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(''); + expect(gpx).toContain('1830'); + expect(gpx).toContain('1902'); + }); + + 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(' { + 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('2. Ancient city'); + 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('3. 2026-05-01'); + + 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('4'); + }); + + 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(''); + }); + + 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(''); + + const noTrk = buildGpx(input, { tracks: false })!; + expect(noTrk).not.toContain(''); + // With tracks off, the geometry place still has coordinates, so it lands as a waypoint. + expect(noTrk).toContain('Line'); + + const noRoutes = buildGpx(input, { dayRoutes: false })!; + expect(noRoutes).not.toContain(''); + }); + + 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 . + const gpx = buildGpx({ tripTitle: 'T', places: [place({ route_geometry })], days: [] })!; + expect(gpx).not.toContain(''); + expect(gpx).toContain(' { + 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 ' })], days: [] })!; + expect(XMLValidator.validate(gpx)).toBe(true); + expect(parser.parse(gpx).gpx.wpt[0].name).toBe('Bar & Grill '); + }); +}); + +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 ')).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'); + }); +}); diff --git a/shared/src/i18n/ar/dayplan.ts b/shared/src/i18n/ar/dayplan.ts index 41bc29e59..522ba5a26 100644 --- a/shared/src/i18n/ar/dayplan.ts +++ b/shared/src/i18n/ar/dayplan.ts @@ -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 diff --git a/shared/src/i18n/ar/mobileTrip.ts b/shared/src/i18n/ar/mobileTrip.ts index 63ae892d9..0a8505e65 100644 --- a/shared/src/i18n/ar/mobileTrip.ts +++ b/shared/src/i18n/ar/mobileTrip.ts @@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.collabFeatureDisabled': 'هذه الميزة معطّلة لهذه الرحلة', 'mobileTrip.compactView': 'عرض مضغوط', 'mobileTrip.dayTitlePlaceholder': 'عنوان اليوم', - 'mobileTrip.exportCalendar': 'تصدير التقويم', + 'mobileTrip.export': 'تصدير', 'mobileTrip.filesEmpty': 'لا توجد ملفات بعد', 'mobileTrip.filesNext': 'الملف التالي', 'mobileTrip.filesPrev': 'الملف السابق', diff --git a/shared/src/i18n/br/dayplan.ts b/shared/src/i18n/br/dayplan.ts index 0672d3a3d..a532ac796 100644 --- a/shared/src/i18n/br/dayplan.ts +++ b/shared/src/i18n/br/dayplan.ts @@ -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?', diff --git a/shared/src/i18n/br/mobileTrip.ts b/shared/src/i18n/br/mobileTrip.ts index 9ca5118bc..c39491169 100644 --- a/shared/src/i18n/br/mobileTrip.ts +++ b/shared/src/i18n/br/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/ca/dayplan.ts b/shared/src/i18n/ca/dayplan.ts index 309398a81..99ff78b63 100644 --- a/shared/src/i18n/ca/dayplan.ts +++ b/shared/src/i18n/ca/dayplan.ts @@ -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?", diff --git a/shared/src/i18n/ca/mobileTrip.ts b/shared/src/i18n/ca/mobileTrip.ts index da9ca2fea..0edad17b6 100644 --- a/shared/src/i18n/ca/mobileTrip.ts +++ b/shared/src/i18n/ca/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/cs/dayplan.ts b/shared/src/i18n/cs/dayplan.ts index f590a2ef2..aaf1e931f 100644 --- a/shared/src/i18n/cs/dayplan.ts +++ b/shared/src/i18n/cs/dayplan.ts @@ -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?', diff --git a/shared/src/i18n/cs/mobileTrip.ts b/shared/src/i18n/cs/mobileTrip.ts index ab6021efb..26669f308 100644 --- a/shared/src/i18n/cs/mobileTrip.ts +++ b/shared/src/i18n/cs/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/de/dayplan.ts b/shared/src/i18n/de/dayplan.ts index fa9667db1..14f461a5b 100644 --- a/shared/src/i18n/de/dayplan.ts +++ b/shared/src/i18n/de/dayplan.ts @@ -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...', diff --git a/shared/src/i18n/de/mobileTrip.ts b/shared/src/i18n/de/mobileTrip.ts index 40746694a..5f694c44b 100644 --- a/shared/src/i18n/de/mobileTrip.ts +++ b/shared/src/i18n/de/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/en/dayplan.ts b/shared/src/i18n/en/dayplan.ts index 56176aac7..e2b1d5699 100644 --- a/shared/src/i18n/en/dayplan.ts +++ b/shared/src/i18n/en/dayplan.ts @@ -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...', diff --git a/shared/src/i18n/en/mobileTrip.ts b/shared/src/i18n/en/mobileTrip.ts index b7defc2e1..ad053c234 100644 --- a/shared/src/i18n/en/mobileTrip.ts +++ b/shared/src/i18n/en/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/es/dayplan.ts b/shared/src/i18n/es/dayplan.ts index f57377228..0e652d7a9 100644 --- a/shared/src/i18n/es/dayplan.ts +++ b/shared/src/i18n/es/dayplan.ts @@ -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?', diff --git a/shared/src/i18n/es/mobileTrip.ts b/shared/src/i18n/es/mobileTrip.ts index 85eda8b7f..3f1e31ed0 100644 --- a/shared/src/i18n/es/mobileTrip.ts +++ b/shared/src/i18n/es/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/fr/dayplan.ts b/shared/src/i18n/fr/dayplan.ts index 51cdfaa6d..30961ac4b 100644 --- a/shared/src/i18n/fr/dayplan.ts +++ b/shared/src/i18n/fr/dayplan.ts @@ -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 ?", diff --git a/shared/src/i18n/fr/mobileTrip.ts b/shared/src/i18n/fr/mobileTrip.ts index e3e14c98d..7ff8e14ab 100644 --- a/shared/src/i18n/fr/mobileTrip.ts +++ b/shared/src/i18n/fr/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/gr/dayplan.ts b/shared/src/i18n/gr/dayplan.ts index 84b682f32..0a985ff0f 100644 --- a/shared/src/i18n/gr/dayplan.ts +++ b/shared/src/i18n/gr/dayplan.ts @@ -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': 'Αναζήτηση τοποθεσιών...', diff --git a/shared/src/i18n/gr/mobileTrip.ts b/shared/src/i18n/gr/mobileTrip.ts index 2297ca6eb..1630ade89 100644 --- a/shared/src/i18n/gr/mobileTrip.ts +++ b/shared/src/i18n/gr/mobileTrip.ts @@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.collabFeatureDisabled': 'Αυτή η λειτουργία είναι απενεργοποιημένη για αυτό το ταξίδι', 'mobileTrip.compactView': 'Συμπαγής προβολή', 'mobileTrip.dayTitlePlaceholder': 'Τίτλος ημέρας', - 'mobileTrip.exportCalendar': 'Εξαγωγή Ημερολογίου', + 'mobileTrip.export': 'Εξαγωγή', 'mobileTrip.filesEmpty': 'Δεν υπάρχουν αρχεία ακόμη', 'mobileTrip.filesNext': 'Επόμενο αρχείο', 'mobileTrip.filesPrev': 'Προηγούμενο αρχείο', diff --git a/shared/src/i18n/hu/dayplan.ts b/shared/src/i18n/hu/dayplan.ts index fef066cab..a3996e340 100644 --- a/shared/src/i18n/hu/dayplan.ts +++ b/shared/src/i18n/hu/dayplan.ts @@ -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?', diff --git a/shared/src/i18n/hu/mobileTrip.ts b/shared/src/i18n/hu/mobileTrip.ts index f60900dff..97d1bdbf7 100644 --- a/shared/src/i18n/hu/mobileTrip.ts +++ b/shared/src/i18n/hu/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/id/dayplan.ts b/shared/src/i18n/id/dayplan.ts index 584fde494..ad6b3555e 100644 --- a/shared/src/i18n/id/dayplan.ts +++ b/shared/src/i18n/id/dayplan.ts @@ -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...', diff --git a/shared/src/i18n/id/mobileTrip.ts b/shared/src/i18n/id/mobileTrip.ts index 81d271e64..b3b34c80c 100644 --- a/shared/src/i18n/id/mobileTrip.ts +++ b/shared/src/i18n/id/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/it/dayplan.ts b/shared/src/i18n/it/dayplan.ts index 3956f1778..dbde95b38 100644 --- a/shared/src/i18n/it/dayplan.ts +++ b/shared/src/i18n/it/dayplan.ts @@ -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?", diff --git a/shared/src/i18n/it/mobileTrip.ts b/shared/src/i18n/it/mobileTrip.ts index 8078dcb58..b43cc4b1f 100644 --- a/shared/src/i18n/it/mobileTrip.ts +++ b/shared/src/i18n/it/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/ja/dayplan.ts b/shared/src/i18n/ja/dayplan.ts index 7fe19637c..c1eea7d2f 100644 --- a/shared/src/i18n/ja/dayplan.ts +++ b/shared/src/i18n/ja/dayplan.ts @@ -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': '場所を検索…', diff --git a/shared/src/i18n/ja/mobileTrip.ts b/shared/src/i18n/ja/mobileTrip.ts index 3625e983e..f8901bc06 100644 --- a/shared/src/i18n/ja/mobileTrip.ts +++ b/shared/src/i18n/ja/mobileTrip.ts @@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.collabFeatureDisabled': 'この機能はこの旅行では無効になっています', 'mobileTrip.compactView': 'コンパクト表示', 'mobileTrip.dayTitlePlaceholder': '日のタイトル', - 'mobileTrip.exportCalendar': 'カレンダーをエクスポート', + 'mobileTrip.export': '書き出し', 'mobileTrip.filesEmpty': 'ファイルはまだありません', 'mobileTrip.filesNext': '次のファイル', 'mobileTrip.filesPrev': '前のファイル', diff --git a/shared/src/i18n/ko/dayplan.ts b/shared/src/i18n/ko/dayplan.ts index d3009eaf3..598228e0e 100644 --- a/shared/src/i18n/ko/dayplan.ts +++ b/shared/src/i18n/ko/dayplan.ts @@ -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': '장소 검색...', diff --git a/shared/src/i18n/ko/mobileTrip.ts b/shared/src/i18n/ko/mobileTrip.ts index 0fd0b7114..01f941f9b 100644 --- a/shared/src/i18n/ko/mobileTrip.ts +++ b/shared/src/i18n/ko/mobileTrip.ts @@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.collabFeatureDisabled': '이 여행에서는 이 기능이 비활성화되어 있습니다', 'mobileTrip.compactView': '간략히 보기', 'mobileTrip.dayTitlePlaceholder': '일차 제목', - 'mobileTrip.exportCalendar': '캘린더 내보내기', + 'mobileTrip.export': '내보내기', 'mobileTrip.filesEmpty': '아직 파일이 없습니다', 'mobileTrip.filesNext': '다음 파일', 'mobileTrip.filesPrev': '이전 파일', diff --git a/shared/src/i18n/nl/dayplan.ts b/shared/src/i18n/nl/dayplan.ts index 57f1150b4..564928bd5 100644 --- a/shared/src/i18n/nl/dayplan.ts +++ b/shared/src/i18n/nl/dayplan.ts @@ -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?', diff --git a/shared/src/i18n/nl/mobileTrip.ts b/shared/src/i18n/nl/mobileTrip.ts index 44966c3b1..13c966287 100644 --- a/shared/src/i18n/nl/mobileTrip.ts +++ b/shared/src/i18n/nl/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/pl/dayplan.ts b/shared/src/i18n/pl/dayplan.ts index 34cd8f95e..cfb968522 100644 --- a/shared/src/i18n/pl/dayplan.ts +++ b/shared/src/i18n/pl/dayplan.ts @@ -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...', diff --git a/shared/src/i18n/pl/mobileTrip.ts b/shared/src/i18n/pl/mobileTrip.ts index 25061c51f..edb335c26 100644 --- a/shared/src/i18n/pl/mobileTrip.ts +++ b/shared/src/i18n/pl/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/ru/dayplan.ts b/shared/src/i18n/ru/dayplan.ts index 925106cf6..c59ad2ef3 100644 --- a/shared/src/i18n/ru/dayplan.ts +++ b/shared/src/i18n/ru/dayplan.ts @@ -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': 'Удалить время?', diff --git a/shared/src/i18n/ru/mobileTrip.ts b/shared/src/i18n/ru/mobileTrip.ts index ac921a028..0b0e5768b 100644 --- a/shared/src/i18n/ru/mobileTrip.ts +++ b/shared/src/i18n/ru/mobileTrip.ts @@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.collabFeatureDisabled': 'Эта функция отключена для этой поездки', 'mobileTrip.compactView': 'Компактный вид', 'mobileTrip.dayTitlePlaceholder': 'Название дня', - 'mobileTrip.exportCalendar': 'Экспорт календаря', + 'mobileTrip.export': 'Экспорт', 'mobileTrip.filesEmpty': 'Файлов пока нет', 'mobileTrip.filesNext': 'Следующий файл', 'mobileTrip.filesPrev': 'Предыдущий файл', diff --git a/shared/src/i18n/sv/dayplan.ts b/shared/src/i18n/sv/dayplan.ts index b5673b439..5842c5aa4 100644 --- a/shared/src/i18n/sv/dayplan.ts +++ b/shared/src/i18n/sv/dayplan.ts @@ -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...', diff --git a/shared/src/i18n/sv/mobileTrip.ts b/shared/src/i18n/sv/mobileTrip.ts index 4d376634c..f052a7922 100644 --- a/shared/src/i18n/sv/mobileTrip.ts +++ b/shared/src/i18n/sv/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/tr/dayplan.ts b/shared/src/i18n/tr/dayplan.ts index 86b5d1b01..604b462a8 100644 --- a/shared/src/i18n/tr/dayplan.ts +++ b/shared/src/i18n/tr/dayplan.ts @@ -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...', diff --git a/shared/src/i18n/tr/mobileTrip.ts b/shared/src/i18n/tr/mobileTrip.ts index 45e22d8a4..a047e1543 100644 --- a/shared/src/i18n/tr/mobileTrip.ts +++ b/shared/src/i18n/tr/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/uk/dayplan.ts b/shared/src/i18n/uk/dayplan.ts index b8d0ddca6..f6af31419 100644 --- a/shared/src/i18n/uk/dayplan.ts +++ b/shared/src/i18n/uk/dayplan.ts @@ -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': 'Видалити час?', diff --git a/shared/src/i18n/uk/mobileTrip.ts b/shared/src/i18n/uk/mobileTrip.ts index cfb559f01..cec203310 100644 --- a/shared/src/i18n/uk/mobileTrip.ts +++ b/shared/src/i18n/uk/mobileTrip.ts @@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.collabFeatureDisabled': 'Цю функцію вимкнено для цієї подорожі', 'mobileTrip.compactView': 'Компактний вигляд', 'mobileTrip.dayTitlePlaceholder': 'Назва дня', - 'mobileTrip.exportCalendar': 'Експорт календаря', + 'mobileTrip.export': 'Експорт', 'mobileTrip.filesEmpty': 'Файлів поки немає', 'mobileTrip.filesNext': 'Наступний файл', 'mobileTrip.filesPrev': 'Попередній файл', diff --git a/shared/src/i18n/vi/dayplan.ts b/shared/src/i18n/vi/dayplan.ts index dbb16bb19..e0cc97c8f 100644 --- a/shared/src/i18n/vi/dayplan.ts +++ b/shared/src/i18n/vi/dayplan.ts @@ -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...', diff --git a/shared/src/i18n/vi/mobileTrip.ts b/shared/src/i18n/vi/mobileTrip.ts index c7c27ca31..3b9e6b764 100644 --- a/shared/src/i18n/vi/mobileTrip.ts +++ b/shared/src/i18n/vi/mobileTrip.ts @@ -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', diff --git a/shared/src/i18n/zh-TW/dayplan.ts b/shared/src/i18n/zh-TW/dayplan.ts index 745a0ddd8..12f6a0a35 100644 --- a/shared/src/i18n/zh-TW/dayplan.ts +++ b/shared/src/i18n/zh-TW/dayplan.ts @@ -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': '移除時間?', diff --git a/shared/src/i18n/zh-TW/mobileTrip.ts b/shared/src/i18n/zh-TW/mobileTrip.ts index 5b6d03e7e..1b2add723 100644 --- a/shared/src/i18n/zh-TW/mobileTrip.ts +++ b/shared/src/i18n/zh-TW/mobileTrip.ts @@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.collabFeatureDisabled': '此功能已針對本次行程停用', 'mobileTrip.compactView': '精簡檢視', 'mobileTrip.dayTitlePlaceholder': '天數標題', - 'mobileTrip.exportCalendar': '匯出行事曆', + 'mobileTrip.export': '匯出', 'mobileTrip.filesEmpty': '暫無檔案', 'mobileTrip.filesNext': '下一個檔案', 'mobileTrip.filesPrev': '上一個檔案', diff --git a/shared/src/i18n/zh/dayplan.ts b/shared/src/i18n/zh/dayplan.ts index 005261ee0..30c456b84 100644 --- a/shared/src/i18n/zh/dayplan.ts +++ b/shared/src/i18n/zh/dayplan.ts @@ -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': '移除时间?', diff --git a/shared/src/i18n/zh/mobileTrip.ts b/shared/src/i18n/zh/mobileTrip.ts index 010192fc9..77255d0c5 100644 --- a/shared/src/i18n/zh/mobileTrip.ts +++ b/shared/src/i18n/zh/mobileTrip.ts @@ -11,7 +11,7 @@ const mobileTrip: TranslationStrings = { 'mobileTrip.collabFeatureDisabled': '此功能已针对本次行程停用', 'mobileTrip.compactView': '紧凑视图', 'mobileTrip.dayTitlePlaceholder': '天数标题', - 'mobileTrip.exportCalendar': '导出日历', + 'mobileTrip.export': '导出', 'mobileTrip.filesEmpty': '暂无文件', 'mobileTrip.filesNext': '下一个文件', 'mobileTrip.filesPrev': '上一个文件', diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index e2fe3fe7f..9c3fa5379 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -174,6 +174,18 @@ export const placeImportGpxRequestSchema = z.object({ }); export type PlaceImportGpxRequest = z.infer; +/** + * 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; + /** KML/KMZ import (multipart/form-data); same string-field contract as GPX. */ export const placeImportMapRequestSchema = z.object({ importPoints: z.string().optional(), diff --git a/wiki/Map-Features.md b/wiki/Map-Features.md index bac70754a..73b79e6e7 100644 --- a/wiki/Map-Features.md +++ b/wiki/Map-Features.md @@ -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.