From 9ce6eed9ad627ca5149d418393512141b81f7d7d Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:45:29 +0200 Subject: [PATCH] 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. --- .../Planner/DayPlanSidebarToolbar.test.tsx | 79 ++++++++ .../Planner/DayPlanSidebarToolbar.tsx | 97 +++++++++- .../screens/trip/sheets/MExportSheet.tsx | 47 ++++- .../mobile/screens/trip/sheets/MMehrSheet.tsx | 4 +- server/src/nest/places/gpx-export.helpers.ts | Bin 0 -> 6030 bytes server/src/nest/places/places.controller.ts | 31 +++ server/src/nest/places/places.dto.ts | 2 + server/src/nest/places/places.service.ts | 50 +++++ server/tests/e2e/places.e2e.test.ts | 76 +++++++- .../unit/nest/gpx-export.helpers.test.ts | 177 ++++++++++++++++++ shared/src/i18n/ar/dayplan.ts | 6 + shared/src/i18n/ar/mobileTrip.ts | 2 +- shared/src/i18n/br/dayplan.ts | 6 + shared/src/i18n/br/mobileTrip.ts | 2 +- shared/src/i18n/ca/dayplan.ts | 6 + shared/src/i18n/ca/mobileTrip.ts | 2 +- shared/src/i18n/cs/dayplan.ts | 6 + shared/src/i18n/cs/mobileTrip.ts | 2 +- shared/src/i18n/de/dayplan.ts | 6 + shared/src/i18n/de/mobileTrip.ts | 2 +- shared/src/i18n/en/dayplan.ts | 6 + shared/src/i18n/en/mobileTrip.ts | 2 +- shared/src/i18n/es/dayplan.ts | 6 + shared/src/i18n/es/mobileTrip.ts | 2 +- shared/src/i18n/fr/dayplan.ts | 6 + shared/src/i18n/fr/mobileTrip.ts | 2 +- shared/src/i18n/gr/dayplan.ts | 6 + shared/src/i18n/gr/mobileTrip.ts | 2 +- shared/src/i18n/hu/dayplan.ts | 6 + shared/src/i18n/hu/mobileTrip.ts | 2 +- shared/src/i18n/id/dayplan.ts | 6 + shared/src/i18n/id/mobileTrip.ts | 2 +- shared/src/i18n/it/dayplan.ts | 6 + shared/src/i18n/it/mobileTrip.ts | 2 +- shared/src/i18n/ja/dayplan.ts | 6 + shared/src/i18n/ja/mobileTrip.ts | 2 +- shared/src/i18n/ko/dayplan.ts | 6 + shared/src/i18n/ko/mobileTrip.ts | 2 +- shared/src/i18n/nl/dayplan.ts | 6 + shared/src/i18n/nl/mobileTrip.ts | 2 +- shared/src/i18n/pl/dayplan.ts | 6 + shared/src/i18n/pl/mobileTrip.ts | 2 +- shared/src/i18n/ru/dayplan.ts | 6 + shared/src/i18n/ru/mobileTrip.ts | 2 +- shared/src/i18n/sv/dayplan.ts | 6 + shared/src/i18n/sv/mobileTrip.ts | 2 +- shared/src/i18n/tr/dayplan.ts | 6 + shared/src/i18n/tr/mobileTrip.ts | 2 +- shared/src/i18n/uk/dayplan.ts | 6 + shared/src/i18n/uk/mobileTrip.ts | 2 +- shared/src/i18n/vi/dayplan.ts | 6 + shared/src/i18n/vi/mobileTrip.ts | 2 +- shared/src/i18n/zh-TW/dayplan.ts | 6 + shared/src/i18n/zh-TW/mobileTrip.ts | 2 +- shared/src/i18n/zh/dayplan.ts | 6 + shared/src/i18n/zh/mobileTrip.ts | 2 +- shared/src/place/place.schema.ts | 12 ++ wiki/Map-Features.md | 12 ++ 58 files changed, 735 insertions(+), 36 deletions(-) create mode 100644 server/src/nest/places/gpx-export.helpers.ts create mode 100644 server/tests/unit/nest/gpx-export.helpers.test.ts 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 0000000000000000000000000000000000000000..41b8788765a47549f4f926d5ac2f366fb4425763 GIT binary patch literal 6030 zcmbVQ{g2zY5$(_UCpsyLvz^-VZhGhy&VJ-O=-qX*NK)W{y+uq*w5_g83M3ud-MIbN z`(`M~vg2He2DtNzB8S77H*X%5FG}kI-P13B|M~ecFEZ_DOH*eT)R`(D2J1yJD3$Zt zb)GzUFnsjr0X?EO*T2xp4|v&*1A&NK`Mw;7Am94~%3QRyALW}7%*poJ<+9(Vc^BBZl~ zUaAe;aax?VhJSei2Ys$QPG27`wM&B>AUuJl){WSvqD*Z%o_w&S=M3N=h~+}vX+ENM zQ=NulLo;i$`cQpZ&GU55v0SlBgV@3*Spx$|Nrq%*9O|Lqfu2=?gKLZh zy=FvVOQUrL4?;rE%qAW|Vq1qLA_yCC?1MvC61ExF!*ke}0zCAlT)z^V*Ni2)e?Vja zp%ISg5SJ%-Mutw-FG#kv{1{V;H|{C4sKO(i8;};~1@o3rC0(h4a-vxJ4B5P#haCEx>~GK66fO z&hGcoL9HA=;hfbQ(e~oW1Dy5ic>9|{A&8d%TKS>9J9` zZ|?S1;Ok|-mDe+&V9$857VjE%wuTz4Jl9Nhl35IkY9Ht~oB zK-)<3^kPSWLPm9prG|eHVqK^L^!V@7Q(7!q98#ihL?TgU11dFsxzLX3qyj0p&fDS> zlP+L+r!|<0sbiWK_#gM4hDxV}1tA%mdo75XE=|g19vQlea*yTPYw&W^?u=bkX^U6~WaK4s!bFnD6Ym612wfKy2@+=in-fI4TMWsTg775w^4pEp zdIwH$yx~Sw6XNnYMLXyb)Q#`8L1SV@sEkzG9@#}^_UQ9xlE0Edn;AOf89nZCZqd`0 zn*ArqbYQUIfK}*a$<4{FoUoaJW_B>+w4Naj#Kk!0>)b$ObYW5)c=*K&GsE$Zd4Z_D z0+`PCoxY&Qbh+=_W0AoE^cUzIa0n}#(2oEMT9Q<@$e`SaNeH8b8Z|v1 zp0YMlAXvz~6(olVI3ZeqLm^i!eyU8G^8k1CiCynkZEVyN-Ie3D81kBX`>u#Tqkb|y z6}Pw`Do{h{c;xg(Rwu2lf0>}B7u{YzvT{?llIsDF z|3pN(oj!FK9E~fI!01Y?SIT zCa09y6a;J_INk{_LP9c1S92%`MubNfh@@INn=Mm7p;p+aWnf4E28qFwQ#>8p2$<(>wjmbTU4}qy-!lqgKDnLOq1g?>xoU6+3;_QS7>7Axl0V4PVUFUIy*&Nj z@RH<;!TPcz*~N`%RGqFFKge&LMOI!CAC68_{q>n!dJ6AYO~tarLsOJ7uoy`yX>n9P zF<6j9BppcPOB&zBL5diZg54#Kd$*eC0>r{b_EUo2)@~C9>-nPoA~hV)lcnL*xh)3F%O{}4Tr1M3b&=w&YXfwNuUOYkQBqy;hsTRHCp@pVCCx1 ziFLE#z^$OvO4uQouF^d7$Pjm+GovX_#CHz zTxi<*D?u#S=6+wtlZd&am2B<`cs%R_Jgb#zo`uI_vsxhGS$N!^C5hy_cXMf|BxWMN zT>u5e1TNwW#A{YjXtbU+5-q_N{B7<9XdU8e@G|$M_4wL|RxsBJ@?1VZHWFQg7``2% zdrW|aJJI^wGvZ{q7U0S0ROg?#5AnU+Z+y@Trll#`7bK!Wv)I#pX!ZEMitm@Zk9dt@ zLn3OQp)(FXrtCD?PK-?yt}?8`s=2~S31l_CIpqJ+-OknPpI&x)G7Rq29v7N0$a1eH zg&r8(q6TusF?PV+&e>w%^h~d#9jB$ab%!^<8~px1fB5y+;poxj*~RDEKMiN7G|2vg ydB6IDH*gHsjW`Q?r@DL6r@x#Wt|PHG=m-y17Y('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.