diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index 43f6cc3d7..daccac71b 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -1,10 +1,10 @@ { "src": { "@typescript-eslint/no-non-null-assertion": 404, - "@typescript-eslint/no-misused-promises": 876, + "@typescript-eslint/no-misused-promises": 875, "no-console": 88, "complexity": 230, - "@typescript-eslint/no-explicit-any": 148, + "@typescript-eslint/no-explicit-any": 147, "trek/no-swallowed-catch": 133, "max-params": 35, "trek/store-without-selector": 69, @@ -12,14 +12,14 @@ "react-hooks/set-state-in-effect": 197, "@typescript-eslint/no-floating-promises": 167, "react-hooks/exhaustive-deps": 121, - "@typescript-eslint/no-unused-vars": 155, + "@typescript-eslint/no-unused-vars": 130, "react-hooks/immutability": 28, "max-lines-per-function": 133, "react-hooks/static-components": 78, "react-hooks/preserve-manual-memoization": 15, "no-useless-assignment": 7, "no-empty": 19, - "trek/no-window-globals": 42, + "trek/no-window-globals": 41, "react-refresh/only-export-components": 46, "unused-disable-directive": 6, "max-depth": 21, @@ -35,7 +35,7 @@ }, "tests": { "@typescript-eslint/no-explicit-any": 491, - "@typescript-eslint/no-non-null-assertion": 1949, + "@typescript-eslint/no-non-null-assertion": 1946, "@typescript-eslint/no-misused-promises": 22, "@typescript-eslint/no-unused-vars": 51, "@typescript-eslint/no-floating-promises": 33, diff --git a/client/scripts/format-baseline.json b/client/scripts/format-baseline.json index 8edbecb2b..0666c49ce 100644 --- a/client/scripts/format-baseline.json +++ b/client/scripts/format-baseline.json @@ -1028,7 +1028,6 @@ "src/components/Todo/useTodoList.ts", "src/components/Tours/PlacesToursModeSwitch.tsx", "src/components/Tours/TourDetailDialog.test.tsx", - "src/components/Tours/TourDetailDialog.tsx", "src/components/Tours/TourListRow.tsx", "src/components/Tours/ToursSidebar.test.tsx", "src/components/Tours/ToursSidebar.tsx", diff --git a/client/scripts/offline-baseline.json b/client/scripts/offline-baseline.json index 537743af4..efdcea437 100644 --- a/client/scripts/offline-baseline.json +++ b/client/scripts/offline-baseline.json @@ -130,7 +130,6 @@ "src/mobile/screens/trip/tabs/MCollabNotes.tsx", "src/pages/JourneyDetailPage.tsx", "src/pages/OAuthAuthorizePage.tsx", - "src/pages/TripPlannerPage.tsx", "src/pages/admin/AdminSettingsTab.tsx", "src/pages/admin/useAdmin.ts", "src/pages/admin/useNotificationDefaults.ts", diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 7edf68862..74f8a7941 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -1,6 +1,6 @@ { "e2e/help/fixtures.ts": 2291, - "src/api/client.ts": 1916, + "src/api/client.ts": 1881, "src/components/Admin/AdminPluginsPanel.test.tsx": 2704, "src/components/Admin/AdminPluginsPanel.tsx": 1667, "src/components/Budget/CostsPanel.test.tsx": 2764, @@ -24,7 +24,7 @@ "src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507, "src/pages/JourneyDetailPage.test.tsx": 4030, "src/pages/LoginPage.tsx": 1348, - "src/pages/TripPlannerPage.tsx": 1153, + "src/pages/TripPlannerPage.tsx": 1148, "src/pages/atlas/useAtlas.ts": 1287, "src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518, "src/pages/tripPlanner/useTripPlanner.test.tsx": 3309, diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json index b183cf908..0d713ffbc 100644 --- a/client/scripts/strict-baseline.json +++ b/client/scripts/strict-baseline.json @@ -89,7 +89,7 @@ "src/components/Todo/todoListModel.ts": 1, "src/components/Tours/planner/TourPlannerPanels.tsx": 2, "src/components/Tours/planner/useTourPlanner.ts": 2, - "src/components/Tours/tourParts.tsx": 8, + "src/components/Tours/tourParts.tsx": 5, "src/components/Trips/TripFormModal.tsx": 3, "src/components/Trips/TripMembersModal.tsx": 1, "src/components/Vacay/VacayCalendar.tsx": 1, diff --git a/client/src/api/client.ts b/client/src/api/client.ts index bff0db6b0..76242330a 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode' import axios, { AxiosInstance } from 'axios' import type { z } from 'zod' import type { Day, Place, Trip } from '../types' -import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared' +import type { AdminFeatureToggleResponse, MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared' import { randomId } from '../utils/randomId' import { postProviderPhotosInBatches } from './providerPhotoBatches' import { @@ -525,6 +525,8 @@ export const toursApi = { apiClient.post(`/trips/${tripId}/tours`, body).then(r => r.data), update: (tripId: number | string, placeId: number | string, body: TourCreateRequest): Promise => apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data), + restoreRecorded: (tripId: number | string, placeId: number | string): Promise => + apiClient.post(`/trips/${tripId}/tours/${placeId}/restore-recorded`).then(r => r.data), importGpx: (tripId: number | string, file: File): Promise => { const fd = new FormData() fd.append('file', file) @@ -706,6 +708,10 @@ export const adminApi = { checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data), updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), + getToursWanderer: (): Promise => + apiClient.get('/admin/tours-wanderer').then(r => r.data), + updateToursWanderer: (enabled: boolean): Promise => + apiClient.put('/admin/tours-wanderer', { enabled }).then(r => r.data), getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data), updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data), getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data), diff --git a/client/src/api/wanderer.ts b/client/src/api/wanderer.ts new file mode 100644 index 000000000..f6d086c86 --- /dev/null +++ b/client/src/api/wanderer.ts @@ -0,0 +1,41 @@ +import type { + WandererConnection, + WandererImportPreview, + WandererImportRequest, + WandererImportResponse, + WandererSaveResponse, + WandererSearchRequest, + WandererSearchResponse, + WandererSettingsRequest, + WandererTestRequest, + WandererTestResponse, +} from '@trek/shared'; +import apiClient from './client'; + +/** + * How long the browser waits for a call that makes the server ask wanderer. + * A preview reads the trail's index entry, its record and its GPX one after + * another, each allowed 15 s on the server, so the shared 8 s would give up + * while the server carries on. + */ +const WANDERER_UPSTREAM_TIMEOUT_MS = 60_000; + +const upstream = { timeout: WANDERER_UPSTREAM_TIMEOUT_MS }; + +export const wandererApi = { + getSettings: (): Promise => apiClient.get('/integrations/wanderer/settings').then((r) => r.data), + saveSettings: (data: WandererSettingsRequest): Promise => + apiClient.put('/integrations/wanderer/settings', data).then((r) => r.data), + disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data), + test: (data: WandererTestRequest): Promise => + apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data), + + search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise => + apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data), + preview: (tripId: number, trailId: string, signal?: AbortSignal): Promise => + apiClient + .get(`/trips/${tripId}/tours/wanderer/trails/${encodeURIComponent(trailId)}`, { ...upstream, signal }) + .then((r) => r.data), + importTrail: (tripId: number, body: WandererImportRequest): Promise => + apiClient.post(`/trips/${tripId}/tours/wanderer/import`, body, upstream).then((r) => r.data), +}; diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index e1d0d42c8..26781b73d 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -1,4 +1,3 @@ -// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-039 import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { delay, http, HttpResponse } from 'msw'; @@ -56,6 +55,12 @@ function isOn(toggle: HTMLElement): boolean { return toggle.getAttribute('aria-pressed') === 'true'; } +/** A shelf of more than one row starts folded; open every one so its rows can be read. */ +async function unfoldShelves(): Promise { + const bars = await screen.findAllByRole('button', { name: /\d+ options?/, expanded: false }); + for (const bar of bars) await userEvent.click(bar); +} + beforeAll(() => { Object.defineProperty(window, 'matchMedia', { writable: true, @@ -72,7 +77,8 @@ beforeEach(() => { seedStore(useSettingsStore, { settings: { dark_mode: false } }); vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined); server.use( - http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })) + http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })), + http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false })) ); }); @@ -228,6 +234,7 @@ describe('AddonManager', () => { ) ); render(); + await unfoldShelves(); // Provider sub-rows are visible under Journey addon await screen.findByText('Unsplash'); @@ -293,6 +300,7 @@ describe('AddonManager', () => { buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }), ])); render(); + await unfoldShelves(); await screen.findByText('Immich'); // immich and synologyphotos ship a vendor glyph, unsplash does not @@ -427,6 +435,8 @@ describe('AddonManager', () => { ])); render(); + await unfoldShelves(); + await screen.findByText('Paperless-ngx'); const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement; expect(paperlessRow.querySelector('svg')).toBeInTheDocument(); @@ -524,6 +534,8 @@ describe('AddonManager', () => { />, ); + await unfoldShelves(); + await screen.findByText('Chat'); expect(screen.getByText('Notes')).toBeInTheDocument(); expect(screen.getByText('Polls')).toBeInTheDocument(); @@ -551,8 +563,9 @@ describe('AddonManager', () => { , + /> ); + await unfoldShelves(); const links = await screen.findByText('Links'); expect(links).toHaveAttribute('title'); @@ -839,4 +852,89 @@ describe('AddonManager', () => { await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument()); expect(urls).toHaveLength(0); }); + it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => { + server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); + render( + + ); + + const bar = await screen.findByRole('button', { name: /5 options/, expanded: false }); + expect(bar).toHaveTextContent('2 on'); + expect(screen.queryByText('Chat')).not.toBeInTheDocument(); + + await userEvent.click(bar); + expect(bar).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Chat')).toBeInTheDocument(); + + await userEvent.click(bar); + expect(screen.queryByText('Chat')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-041: a shelf of one option shows it at once, without a fold', async () => { + server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Lists', icon: 'ListChecks', enabled: true })])); + render(); + + expect(await screen.findByRole('button', { name: 'Bag Tracking' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /\d+ options?/ })).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-042: wanderer hangs off Tours and saves its switch', async () => { + const puts: unknown[] = []; + server.use( + addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]), + http.put('/api/admin/tours-wanderer', async ({ request }) => { + puts.push(await request.json()); + return HttpResponse.json({ enabled: true }); + }) + ); + render( + <> + + + + ); + + const toggle = await screen.findByRole('button', { name: 'wanderer' }); + expect(isOn(toggle)).toBe(false); + await userEvent.click(toggle); + + await waitFor(() => expect(puts).toEqual([{ enabled: true }])); + expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true); + }); + + it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => { + server.use( + addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]), + http.put('/api/admin/tours-wanderer', () => HttpResponse.json({ error: 'nope' }, { status: 500 })) + ); + render( + <> + + + + ); + + await userEvent.click(await screen.findByRole('button', { name: 'wanderer' })); + + await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false)); + }); + + it('FE-ADMIN-ADDON-044: Tours and Road trip show their own icons, not the fallback puzzle', async () => { + server.use( + addonsRoute([ + buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: false }), + buildAddon({ id: 'roadtrip', name: 'Road trip', icon: 'Route', enabled: false }), + ]) + ); + render(); + + const tours = (await screen.findByText('Tours')).closest('article') as HTMLElement; + const roadtrip = screen.getByText('Road trip').closest('article') as HTMLElement; + expect(tours.querySelector('svg.lucide-mountain')).toBeInTheDocument(); + expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument(); + expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index ea57d7ddc..d897642dd 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -1,7 +1,7 @@ import { useId, type ComponentType } from 'react' import { useTranslation } from '../../i18n' import { useIsDark } from '../../hooks/useIsDark' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared' import EmptyState from '../shared/EmptyState' @@ -18,12 +18,16 @@ import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, g import { useAddonManager } from './addons/useAddonManager' import { useLlmParsingConfig } from './addons/useLlmParsingConfig' import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons' +import WandererMark from '../Tours/wanderer/WandererMark' // Keys are the `icon` column from the addons table (see server seeds.ts); anything // unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which // used to land on the fallback. const ICON_MAP = { ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles, + Mountain, + // Road trip was seeded as 'Route'; the car says what it is better than a line does. + Route: CarFront, // Dawarich ships a brand mark rather than a lucide name, the same way the photo // providers below do. Keyed by the string in the addons table. Dawarich: DawarichIcon, @@ -71,6 +75,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, const dark = useIsDark() const { addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions, + toursWanderer, + toggleToursWanderer, } = useAddonManager() if (loading) { @@ -147,6 +153,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, ) }) } + if (addon.id === 'tours' && addon.enabled) { + return ( + } + title={t('admin.toursWanderer.title')} + description={t('admin.toursWanderer.subtitle')} + enabled={toursWanderer} + onToggle={() => void toggleToursWanderer()} + /> + ) + } // The AI-parsing settings hang off their tile like every other sub-feature, // just as a form instead of toggle rows. if (addon.id === 'llm_parsing' && addon.enabled) { diff --git a/client/src/components/Admin/AddonTile.tsx b/client/src/components/Admin/AddonTile.tsx index da39734ec..dff37d989 100644 --- a/client/src/components/Admin/AddonTile.tsx +++ b/client/src/components/Admin/AddonTile.tsx @@ -1,6 +1,13 @@ -import type { ReactNode } from 'react' +import { Children, type ReactNode, isValidElement, useId, useState } from 'react' import ToggleSwitch from '../Settings/ToggleSwitch' import { fs } from '../shared/DialogShell' +import { ChevronDown } from 'lucide-react' +import { useTranslation } from '../../i18n' + +/** How many of the shelf's rows are switched on, read from the rows' own `enabled`. */ +function enabledRows(rows: ReactNode[]): number { + return rows.filter((row) => isValidElement<{ enabled?: boolean }>(row) && row.props.enabled === true).length +} /** * One addon in its type's card: a row like the bag sidebar's (#2541), with the @@ -14,6 +21,10 @@ import { fs } from '../shared/DialogShell' * * The row is not a click target, only the switches are. No lift, no pointer * cursor, no `role="button"`. + * + * A shelf of more than one row starts folded behind a bar that says how many + * options there are and how many are on, so a page of addons stays short and + * the options are still plain to see. */ export default function AddonTile({ icon, @@ -31,6 +42,13 @@ export default function AddonTile({ /** The sub-shelf: `` children. */ children?: ReactNode }) { + const { t } = useTranslation() + const shelfId = useId() + const [open, setOpen] = useState(false) + const rows = Children.toArray(children) + const foldable = rows.length > 1 + const active = enabledRows(rows) + return (
@@ -62,11 +80,40 @@ export default function AddonTile({
- {children && ( + {rows.length > 0 && (
+ {foldable && ( + + )} {/* Sitting inside the parent on its own fill is enough to say these belong to it: no rail, no indent guides. */} -
    {children}
+ {(!foldable || open) && ( +
    {children}
+ )}
)}
diff --git a/client/src/components/Admin/addons/useAddonManager.ts b/client/src/components/Admin/addons/useAddonManager.ts index 05c37729d..52212c5ee 100644 --- a/client/src/components/Admin/addons/useAddonManager.ts +++ b/client/src/components/Admin/addons/useAddonManager.ts @@ -18,6 +18,7 @@ export function useAddonManager() { const refreshGlobalAddons = useAddonStore((s) => s.loadAddons); const [addons, setAddons] = useState([]); const [loading, setLoading] = useState(true); + const [toursWanderer, setToursWanderer] = useState(false); const loadAddons = async () => { try { @@ -30,6 +31,10 @@ export function useAddonManager() { const loadOnMount = useEffectEvent(() => { void loadAddons().finally(() => setLoading(false)); + adminApi + .getToursWanderer() + .then((flag) => setToursWanderer(flag.enabled)) + .catch(() => toast.error(t('admin.addons.toast.error'))); }); useEffect(() => { loadOnMount(); @@ -57,6 +62,21 @@ export function useAddonManager() { toast.success(t('admin.addons.toast.updated')); }; + /** wanderer under Tours: flipped at once, put back when the save fails. */ + const toggleToursWanderer = async () => { + const next = !toursWanderer; + setToursWanderer(next); + try { + await adminApi.updateToursWanderer(next); + } catch { + setToursWanderer(!next); + toast.error(t('admin.addons.toast.error')); + return; + } + void refreshGlobalAddons(); + toast.success(t('admin.addons.toast.updated')); + }; + const asOption = (provider: Addon): ProviderOption => ({ key: provider.id, label: provider.name, @@ -83,5 +103,7 @@ export function useAddonManager() { integrationAddons, providerOptions, documentProviderOptions, + toursWanderer, + toggleToursWanderer, }; } diff --git a/client/src/components/Map/markerDrag.ts b/client/src/components/Map/markerDrag.ts index a89394439..bd1f19722 100644 --- a/client/src/components/Map/markerDrag.ts +++ b/client/src/components/Map/markerDrag.ts @@ -18,18 +18,32 @@ * both end up as a real DOM node, which is all HTML5 drag needs. */ +/** + * Start dragging `placeId` towards the day plan, from any row or marker that holds a + * place (a tour is one too). Every source goes through here, so the day plan cannot + * tell them apart. + */ +export function startPlaceDrag(transfer: DataTransfer | null, placeId: number): void { + transfer?.setData('placeId', String(placeId)) + if (transfer) transfer.effectAllowed = 'copy' + window.__dragData = { placeId: String(placeId) } +} + +/** The drag `startPlaceDrag` began has ended, dropped or not. */ +export function endPlaceDrag(): void { + window.__dragData = null +} + /** Wire a marker element up as a drag source for `placeId`. */ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => void { el.setAttribute('draggable', 'true') const onDragStart = (e: DragEvent) => { - e.dataTransfer?.setData('placeId', String(placeId)) - if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy' - window.__dragData = { placeId: String(placeId) } + startPlaceDrag(e.dataTransfer, placeId) el.classList.add('marker-dragging') } const onDragEnd = () => { - window.__dragData = null + endPlaceDrag() el.classList.remove('marker-dragging') } // The map pans on mousedown anywhere on its surface, and a marker sits on that diff --git a/client/src/components/Planner/DayPlanPlaceSubline.tsx b/client/src/components/Planner/DayPlanPlaceSubline.tsx index 8c111f795..1cca82cd9 100644 --- a/client/src/components/Planner/DayPlanPlaceSubline.tsx +++ b/client/src/components/Planner/DayPlanPlaceSubline.tsx @@ -5,7 +5,7 @@ import { useTranslation } from '../../i18n'; import type { Category, Place } from '../../types'; import { formatTime } from '../../utils/formatters'; import { fs } from '../shared/DialogShell'; -import TourDayRowFacts from '../Tours/TourDayRowFacts'; +import { TourDayRowPills } from '../Tours/TourDayRowFacts'; import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation'; import { TimePill } from './planParts'; @@ -65,7 +65,7 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) { ); } -// Description line; tour stops get two clamped lines instead of one. +// Description line, one line like every stop's. function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) { return (
{text} @@ -94,14 +93,16 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo const description = place.description || place.address || cat?.name; if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null; return ( -
+ // One line like the row's name: when the hover controls come out, the description + // gives way and the pills keep their place instead of wrapping onto a second line. +
{place.place_time && ( {formatTime(place.place_time, locale, timeFormat)} {place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''} )} - {dayRowTour ? : tourTimes && } + {dayRowTour ? : tourTimes && } {description && }
); diff --git a/client/src/components/Planner/DayPlanSidebar.tours.test.tsx b/client/src/components/Planner/DayPlanSidebar.tours.test.tsx index f8d872eae..68f99d6de 100644 --- a/client/src/components/Planner/DayPlanSidebar.tours.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tours.test.tsx @@ -155,7 +155,7 @@ beforeEach(() => { }); describe('DayPlanSidebar', () => { - it('RS-01: previews Tour descriptions in two lines only while Tours is enabled', () => { + it('RS-01: previews a Tour description on one line like every stop, marked only while Tours is enabled', () => { const description = 'A long description of the ridge route, the lake overlook, and the return path.'; const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 }); const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }); @@ -172,9 +172,9 @@ describe('DayPlanSidebar', () => { const view = render(); const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement; expect(tourPreview).toHaveTextContent(description); - expect(tourPreview.style.display).toBe('-webkit-box'); - expect(tourPreview.style.webkitLineClamp).toBe('2'); - expect(tourPreview.style.maxHeight).toBe('2.6em'); + // One line like the row's name, so hover controls make the description give way instead of wrapping. + expect(tourPreview).toHaveStyle({ whiteSpace: 'nowrap', maxHeight: '1.3em' }); + expect(tourPreview.parentElement).toHaveClass('flex-nowrap', 'overflow-hidden'); expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h'); expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min'); expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min'); @@ -231,14 +231,77 @@ describe('DayPlanSidebar', () => { }); useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] }); const view = render(); - expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy(); + // The tile carries the grade, as in the tour lists; the facts sit in the plan's white badges. + expect(screen.getByTestId('plan-tour-icon')).toHaveTextContent('T2'); expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4'); expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100'); - expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min'); - expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min'); + const time = screen.getByTestId('trip-plan-tour-time'); + expect(time).toHaveTextContent('1 h 3 min'); + expect(time).toHaveAccessibleName( + 'Walking time: 53 min, Breaks / additional time: 10 min, Planned total duration: 1 h 3 min' + ); useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] }); view.rerender(); expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument(); expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument(); }); + it('RC-01: an imported tour without a grade shows its type on the tile', () => { + const place = buildPlace({ id: 46, name: 'Recorded loop', tour_place_id: 46 }); + const day = buildDay({ id: 10, title: 'Day 1' }); + const assignment = buildAssignment({ id: 102, day_id: 10, order_index: 0, place }); + const tour = { + place_id: 46, + name: place.name, + tour_type: 'hike' as const, + distance: 6, + elevation_gain: 300, + elevation_loss: 300, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: 1, + max_hiking_difficulty: null, + planned: true, + caution: false, + has_waypoints: false, + }; + const props = makeDefaultProps({ + days: [day], + places: [place], + tourPlaceIds: new Set([46]), + assignments: { '10': [assignment] }, + plannedTourDurations: new Map([ + [46, { tour, walkingMinutes: null, breakMinutes: null, plannedTotalMinutes: null, manuallyOverridden: false }], + ]), + }); + useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] }); + render(); + + expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy(); + expect(screen.queryByTestId('trip-plan-tour-time')).not.toBeInTheDocument(); + }); + + it('RC-01: a tour row offers no booking, a tour is not booked', () => { + const place = buildPlace({ id: 45, name: 'Ridge walk', tour_place_id: 45 }); + const cafe = buildPlace({ id: 47, name: 'Lakeside cafe' }); + const day = buildDay({ id: 10, title: 'Day 1' }); + const assignments = { + '10': [ + buildAssignment({ id: 101, day_id: 10, order_index: 0, place }), + buildAssignment({ id: 103, day_id: 10, order_index: 1, place: cafe }), + ], + }; + const props = makeDefaultProps({ + days: [day], + places: [place, cafe], + tourPlaceIds: new Set([45]), + assignments, + onAddBookingToAssignment: vi.fn(), + }); + useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] }); + render(); + + // The cafe can be booked; the tour beside it cannot. + expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1); + }); }); diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 515885364..8d8ecf710 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -83,7 +83,7 @@ import type { DayDeleteQuestion } from '../../utils/dayImpactLines' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' import type { TourPlannedTimes } from '../Tours/tourPresentation' import type { TourListItem } from '@trek/shared' -import { TourDayRowIcon } from '../Tours/TourDayRowFacts' +import { TourDayRowAvatar } from '../Tours/TourDayRowFacts' import { DayPlanPlaceSubline } from './DayPlanPlaceSubline' export interface DayPlanSidebarProps { @@ -2000,7 +2000,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP onMouseLeave={() => setLockHoverId(null)} className="relative flex flex-none rounded-full" > - {dayRowTour ? : } + {dayRowTour ? : } {/* Hover/locked overlay */} {(lockHoverId === assignment.id || isLocked) && (
} - {canEditDays && onAddBookingToAssignment && ( + {canEditDays && onAddBookingToAssignment && !isTourPlace && ( + + {S.connected && ( + + )} +
+ + {(S.probe || S.lastCheck) && ( +
+ {S.probe ? ( +

{S.probe.message}

+ ) : ( +

{S.lastCheck}

+ )} +
+ )} + + ); +} diff --git a/client/src/components/Settings/useIntegrationGates.test.ts b/client/src/components/Settings/useIntegrationGates.test.ts index 774bed99b..235e12aba 100644 --- a/client/src/components/Settings/useIntegrationGates.test.ts +++ b/client/src/components/Settings/useIntegrationGates.test.ts @@ -52,4 +52,11 @@ describe('useIntegrationGates', () => { renderHook(() => useIntegrationGates({ reloadAddons: true })); expect(loud).toHaveBeenCalledTimes(1); }); + it('RS-03: offers the wanderer connection only while the admin allows wanderer under Tours', () => { + seedAddons(['tours']); + useAddonStore.setState({ toursWanderer: false }); + expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.wandererEnabled).toBe(false); + useAddonStore.setState({ toursWanderer: true }); + expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.wandererEnabled).toBe(true); + }); }); diff --git a/client/src/components/Settings/useIntegrationGates.ts b/client/src/components/Settings/useIntegrationGates.ts index 99f02a43e..8c5aa5c44 100644 --- a/client/src/components/Settings/useIntegrationGates.ts +++ b/client/src/components/Settings/useIntegrationGates.ts @@ -19,6 +19,8 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean }) const airtrailEnabled = addonEnabled('airtrail'); const llmEnabled = addonEnabled('llm_parsing'); const dawarichEnabled = addonEnabled('dawarich'); + // The wanderer connection feeds the Tours workspace, while the admin allows it there. + const wandererEnabled = useAddonStore((s) => s.toursWanderer); const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled || dawarichEnabled; useEffect(() => { @@ -31,6 +33,7 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean }) airtrailEnabled, llmEnabled, dawarichEnabled, + wandererEnabled, hasIntegrations, managed, loadAddons, diff --git a/client/src/components/Settings/useWandererConnection.ts b/client/src/components/Settings/useWandererConnection.ts new file mode 100644 index 000000000..dbb7c4946 --- /dev/null +++ b/client/src/components/Settings/useWandererConnection.ts @@ -0,0 +1,152 @@ +import { + WANDERER_TOKEN_MASK, + WANDERER_TOKEN_PREFIX, + type WandererConnection, + type WandererTestResponse, +} from '@trek/shared'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from '../../i18n'; +import { wandererErrorCode, wandererRepo } from '../../repo/wandererRepo'; +import { useToast } from '../shared/Toast'; +import { wandererErrorText } from '../Tours/wanderer/wandererErrors'; + +/** What the last test found, said in the card; null before a test. */ +export interface WandererProbe { + ok: boolean; + message: string; +} + +type Translate = (key: string, params?: Record) => string; + +/** What a test answered, as the line the card shows under its buttons. */ +function probeOf(result: WandererTestResponse, t: Translate): WandererProbe { + if (!result.ok) return { ok: false, message: wandererErrorText(t, result.code ?? 'unknown') }; + const message = + result.trailCount != null ? t('wanderer.test.okCount', { count: result.trailCount }) : t('wanderer.test.ok'); + return { ok: true, message }; +} + +/** + * The wanderer connection card's logic: the form, saving, testing and + * disconnecting, with every failure turned into a sentence the reader can act + * on. The token field is never filled from the server; blank keeps the stored one. + */ +export function useWandererConnection() { + const { t } = useTranslation(); + const toast = useToast(); + const [url, setUrl] = useState(''); + const [token, setToken] = useState(''); + const [allowInsecureTls, setAllowInsecureTls] = useState(false); + const [saved, setSaved] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [probe, setProbe] = useState(null); + + const errorText = useCallback((code: string) => wandererErrorText(t, code), [t]); + + const apply = useCallback((connection: WandererConnection) => { + setSaved(connection); + setUrl(connection.url); + setAllowInsecureTls(connection.allowInsecureTls); + setToken(''); + }, []); + + useEffect(() => { + let cancelled = false; + wandererRepo + .getSettings() + .then((connection) => { + if (!cancelled) apply(connection); + }) + .catch(() => { + if (!cancelled) toast.error(t('wanderer.loadFailed')); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [apply, t, toast]); + + const typedToken = token.trim(); + /** A typed token that wanderer would read as no token at all, said before it is sent. */ + const tokenInvalid = + typedToken !== '' && typedToken !== WANDERER_TOKEN_MASK && !typedToken.startsWith(WANDERER_TOKEN_PREFIX); + const connected = !!saved?.connected; + const canSave = !!url.trim() && !tokenInvalid && (connected || typedToken !== ''); + + const save = useCallback(async () => { + if (!canSave) return; + setSaving(true); + try { + const result = await wandererRepo.saveSettings({ + url: url.trim(), + token: typedToken || undefined, + allowInsecureTls, + }); + apply(await wandererRepo.getSettings()); + setProbe(null); + toast.success(t('wanderer.saved')); + if (result.warning === 'private_ip') toast.info(t('wanderer.privateIp')); + } catch (error: unknown) { + toast.error(errorText(wandererErrorCode(error))); + } finally { + setSaving(false); + } + }, [allowInsecureTls, apply, canSave, errorText, t, toast, typedToken, url]); + + const test = useCallback(async () => { + if (!url.trim() || tokenInvalid) return; + setTesting(true); + try { + const result = await wandererRepo.test({ url: url.trim(), token: typedToken || undefined, allowInsecureTls }); + setProbe(probeOf(result, t)); + if (!typedToken) setSaved(await wandererRepo.getSettings()); + } catch (error: unknown) { + setProbe({ ok: false, message: errorText(wandererErrorCode(error)) }); + } finally { + setTesting(false); + } + }, [allowInsecureTls, errorText, t, tokenInvalid, typedToken, url]); + + const disconnect = useCallback(async () => { + setSaving(true); + try { + await wandererRepo.disconnect(); + apply(await wandererRepo.getSettings()); + setProbe(null); + toast.success(t('wanderer.disconnected')); + } catch (error: unknown) { + toast.error(errorText(wandererErrorCode(error))); + } finally { + setSaving(false); + } + }, [apply, errorText, t, toast]); + + const lastCheck = + saved?.lastCheckedAt && saved.lastCheckOk === false && saved.lastCheckError + ? errorText(saved.lastCheckError) + : null; + + return { + url, + setUrl, + token, + setToken, + allowInsecureTls, + toggleInsecureTls: () => setAllowInsecureTls((value) => !value), + connected, + loading, + saving, + testing, + probe, + tokenInvalid, + canSave, + lastCheck, + save, + test, + disconnect, + }; +} diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx index a42b28391..74b734b35 100644 --- a/client/src/components/Tours/TourDayRowFacts.tsx +++ b/client/src/components/Tours/TourDayRowFacts.tsx @@ -4,6 +4,8 @@ import { createElement, useId, useState } from 'react'; import { useTranslation } from '../../i18n'; import { useSettingsStore } from '../../store/settingsStore'; import { formatDistance, formatElevation } from '../../utils/units'; +import { WhiteBadge } from '../Planner/planParts'; +import { fs } from '../shared/DialogShell'; import { Tooltip } from '../shared/Tooltip'; import { tourDayRowIcon } from './tourDayRowIcon'; import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation'; @@ -73,6 +75,121 @@ function Fact({ ); } +/** + * A tour's tile in the day plan and in its detail: the T-scale grade in a round + * tile, the same tile the tour lists use, so a tour reads alike wherever it + * shows. A GPX import has no grade and shows its type's icon instead. + */ +export function TourDayRowAvatar({ + tour, + size = 32, +}: { + tour: Pick; + size?: number; +}) { + const { t } = useTranslation(); + const level = tour.max_hiking_difficulty; + const known = ['hike', 'bike', 'walk', 'kayak'].includes(tour.tour_type); + const label = + level != null + ? t(`tours.planner.difficulty.t${level}`) + : known + ? t(`tourTypes.${tour.tour_type}`) + : t('tours.mode.tours'); + return ( + = 44 ? 17 : 12, 'body') }} + > + {level != null + ? `T${level}` + : createElement(tourDayRowIcon(tour.tour_type), { + size: Math.round(size * 0.46), + strokeWidth: 1.9, + className: 'text-content-secondary', + 'aria-hidden': 'true', + })} + + ); +} + +/** One fact of a tour as the white badge a place's time sits in, named by its tooltip. */ +function TourDayRowPill({ + icon: Icon, + value, + label, + testId, +}: { + icon: LucideIcon; + value: string; + label: string; + testId: string; +}) { + return ( + + + }> + {value} + + + + ); +} + +/** + * A tour's facts on its day-plan row, in the badges every other stop uses: the + * time it takes (the planned total where there is one, otherwise the walking + * time, the parts in the tooltip), how far and how much climbing. + */ +export function TourDayRowPills({ tour }: { tour: TourListItem }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric'; + const times = tourPlannedTimes(tour); + const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes; + const timeParts = [ + times.walkingMinutes != null + ? `${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(times.walkingMinutes)}` + : null, + times.breakMinutes + ? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}` + : null, + times.plannedTotalMinutes != null + ? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}` + : null, + ].filter((part): part is string => part !== null); + return ( + <> + {shownMinutes != null && ( + + )} + {tour.distance != null && ( + + )} + {tour.elevation_gain != null && ( + + )} + + ); +} + export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) { const { t } = useTranslation(); const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric'; diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx index 7a65ff216..490e1f13d 100644 --- a/client/src/components/Tours/TourDetailDialog.test.tsx +++ b/client/src/components/Tours/TourDetailDialog.test.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import { useState } from 'react' import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TourListItem } from '@trek/shared' @@ -6,6 +6,7 @@ import { buildPlace } from '../../../tests/helpers/factories' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { useSettingsStore } from '../../store/settingsStore' import TourDetailDialog from './TourDetailDialog' +import { useTripStore } from '../../store/tripStore' vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') }) vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') }) @@ -96,16 +97,19 @@ describe('TourDetailDialog', () => { expect(screen.getByText('Maximum altitude')).toBeInTheDocument() expect(screen.getByText('Ascent')).toBeInTheDocument() expect(screen.getByText('Descent')).toBeInTheDocument() - expect(screen.getByText('50 m')).toBeInTheDocument() - expect(screen.getByText('30 m')).toBeInTheDocument() + // The head's pills and the fields below both measure the line as it is now. + expect(screen.getAllByText('50 m').length).toBeGreaterThan(0) + expect(screen.getAllByText('30 m').length).toBeGreaterThan(0) expect(screen.queryByText('999 m')).not.toBeInTheDocument() + expect(screen.queryByText('999 km')).not.toBeInTheDocument() expect(screen.getByText('Bring water')).toBeInTheDocument() expect(screen.queryByText('Opening Hours')).not.toBeInTheDocument() expect(screen.queryByText('Participants')).not.toBeInTheDocument() }) - it('RS-01: shows Tour description and classified HTTPS information link without fetching it', () => { + it('RS-01: shows Tour description and opens the classified HTTPS information link without fetching it', () => { const fetchSpy = vi.spyOn(globalThis, 'fetch') + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) render( { ) expect(screen.getByText('Ridge above the lake')).toBeInTheDocument() - const link = screen.getByRole('link', { name: 'Komoot · komoot.com' }) - expect(link).toHaveAttribute('href', 'https://www.komoot.com/tour/42') - expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow') + // The footer names the provider; the domain rides along in the tooltip. + const website = screen.getByRole('button', { name: 'Website: Komoot' }) + expect(website).toHaveTextContent('Komoot') + fireEvent.click(website) + expect(openSpy).toHaveBeenCalledWith('https://www.komoot.com/tour/42', '_blank', 'noopener,noreferrer') expect(fetchSpy).not.toHaveBeenCalled() fetchSpy.mockRestore() + openSpy.mockRestore() }) it('RS-02: labels calculated walking time and planned total as separate values', () => { render() - expect(screen.getByText('Walking time: 1 h')).toBeInTheDocument() - expect(screen.getByText('Breaks / additional time: 35 min')).toBeInTheDocument() - expect(screen.getByText('Planned total duration: 2 h 5 min · Manual override')).toBeInTheDocument() + // One time in the head, its parts named in full behind it; the fields below keep them apart. + expect(screen.getByLabelText('Walk: 1 h, Breaks: 35 min, Planned (Manual override): 2 h 5 min')).toHaveTextContent( + '2 h 5 min' + ) + expect(screen.getByText('Walk')).toBeInTheDocument() + expect(screen.getByText('Breaks')).toBeInTheDocument() + expect(screen.getByText('Planned (Manual override)')).toBeInTheDocument() }) it('RS-01: rejects an unsafe legacy Tour website for rendering', () => { @@ -176,10 +187,31 @@ describe('TourDetailDialog', () => { expect(defaultProps.onUpdatePlace).toHaveBeenCalledWith(42, { name: 'New route name' }) }) - it('labels GPX imports as view-only planner content', () => { + it('RC-03: names a GPX import as such, without a read-only notice in the plan', () => { render() expect(screen.getByText('GPX import')).toBeInTheDocument() - expect(screen.getByText('GPX tours are view-only and cannot be edited in the planner yet.')).toBeInTheDocument() + expect(screen.queryByText(/view-only/)).not.toBeInTheDocument() + }) + + it('PR1-05B: names the import a tour was planned from while it is still in the trip', () => { + seedStore(useTripStore, { places: [buildPlace({ id: 7, name: 'Recorded ridge' }), place] }) + render() + + expect(screen.getByText('Planned from Recorded ridge')).toBeInTheDocument() + }) + + it('PR1-05B: says nothing of an origin that is gone from the trip', () => { + seedStore(useTripStore, { places: [place] }) + render() + + expect(screen.queryByText(/Planned from/)).not.toBeInTheDocument() + }) + + it('shows the grade on the tile and no second difficulty scale', () => { + render() + + expect(screen.getAllByText('T1').length).toBeGreaterThan(0) + expect(screen.queryByText(/easy/i)).not.toBeInTheDocument() }) }) diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index 557e157dd..17565f605 100644 --- a/client/src/components/Tours/TourDetailDialog.tsx +++ b/client/src/components/Tours/TourDetailDialog.tsx @@ -1,82 +1,296 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { AlertTriangle, Check, ChevronDown, ChevronUp, ExternalLink, File, FileImage, FileText, Info, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Upload } from 'lucide-react' -import Markdown from 'react-markdown' -import remarkBreaks from 'remark-breaks' -import remarkGfm from 'remark-gfm' -import type { TourListItem } from '@trek/shared' -import type { Assignment, AssignmentsMap, Day, Place, TripFile } from '../../types' -import { useTranslation, translateApiError } from '../../i18n' -import { useToast } from '../shared/Toast' -import { useSettingsStore } from '../../store/settingsStore' -import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors' -import TrackColorPicker from '../shared/TrackColorPicker' -import DetailShell from '../shared/DetailShell' -import ElevationProfile from '../shared/ElevationProfile' -import { NavigationMenu } from '../shared/NavigationMenu' -import { markdownLinkComponents } from '../shared/markdownLink' -import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell' -import { Tooltip } from '../shared/Tooltip' -import { BOX } from '../Planner/bookings/bookingParts' -import { SoftPill, TimePill, tintOf } from '../Planner/planParts' -import { analyzeRouteGeometry } from '../../utils/routeGeometry' -import { filesForPlace } from '../../utils/placeFiles' -import { openFile } from '../../utils/fileDownload' -import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation' -import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation' -import { TourMetricFields, TourNotice, TourTile } from './tourParts' -import { useTourPermissions, type TourPermissionProps } from './useTourPermissions' +import type { TourListItem } from '@trek/shared'; +import { + AlertTriangle, + ArrowDown, + ArrowUp, + Check, + ChevronDown, + ChevronUp, + Clock, + ExternalLink, + File, + FileDown, + FileImage, + FileText, + Minus, + Mountain, + Navigation, + Pencil, + Plus, + Route, + Ruler, + Upload, +} from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import Markdown from 'react-markdown'; +import remarkBreaks from 'remark-breaks'; +import remarkGfm from 'remark-gfm'; +import { translateApiError, useTranslation } from '../../i18n'; +import { useSettingsStore } from '../../store/settingsStore'; +import { useTripStore } from '../../store/tripStore'; +import type { Assignment, AssignmentsMap, Day, Place, TripFile } from '../../types'; +import { openFile } from '../../utils/fileDownload'; +import { filesForPlace } from '../../utils/placeFiles'; +import { analyzeRouteGeometry } from '../../utils/routeGeometry'; +import { formatDistance, formatElevation } from '../../utils/units'; +import { inheritedTrackColor, resolveTrackColor } from '../Map/trackColors'; +import { BOX } from '../Planner/bookings/bookingParts'; +import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation'; +import { SoftPill, tintOf } from '../Planner/planParts'; +import DetailShell from '../shared/DetailShell'; +import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell'; +import ElevationProfile from '../shared/ElevationProfile'; +import { NavigationMenu } from '../shared/NavigationMenu'; +import { useToast } from '../shared/Toast'; +import { Tooltip } from '../shared/Tooltip'; +import TrackColorPicker from '../shared/TrackColorPicker'; +import { markdownLinkComponents } from '../shared/markdownLink'; +import { TourDayRowAvatar } from './TourDayRowFacts'; +import { tourDaysOf } from './tourDays'; +import { TourDayPills, TourFactFields, TourMetricFields, type TourMetricField } from './tourParts'; +import { + formatPlannedTourDuration, + hikeSourceBadgeLabel, + tourPlannedTimes, + tourSource, + tourWebsitePresentation, +} from './tourPresentation'; +import { useTourPermissions, type TourPermissionProps } from './useTourPermissions'; interface TourDetailDialogProps extends TourPermissionProps { - tour: TourListItem - place: Place - days?: Day[] - selectedDayId?: number | null - selectedAssignmentId?: number | null - assignments?: AssignmentsMap - files?: TripFile[] - onClose: () => void - onUpdatePlace?: (placeId: number, data: Partial) => Promise | void - onFileUpload?: (formData: FormData) => Promise - onAssignToDay?: (placeId: number, dayId?: number) => void - onRemoveAssignment?: (dayId: number, assignmentId: number) => void - onDelete?: () => void - leftWidth?: number - rightWidth?: number - desktopNonModal?: boolean - readOnly?: boolean + tour: TourListItem; + place: Place; + days?: Day[]; + selectedDayId?: number | null; + selectedAssignmentId?: number | null; + assignments?: AssignmentsMap; + files?: TripFile[]; + onClose: () => void; + onUpdatePlace?: (placeId: number, data: Partial) => Promise | void; + onFileUpload?: (formData: FormData) => Promise; + onAssignToDay?: (placeId: number, dayId?: number) => void; + onRemoveAssignment?: (dayId: number, assignmentId: number) => void; + onDelete?: () => void; + leftWidth?: number; + rightWidth?: number; + desktopNonModal?: boolean; + readOnly?: boolean; /** Desktop-only opener; the map detail stays non-modal and returns focus when closed. */ - desktopFocusReturnTarget?: HTMLElement | null + desktopFocusReturnTarget?: HTMLElement | null; } +/** The pills of the head band: white like a place's, one fact each. */ +const HEAD_PILL = + 'inline-flex flex-none items-center gap-1 h-[22px] whitespace-nowrap rounded-full px-2 font-geist font-medium tabular-nums text-content-secondary bg-surface-card shadow-sm'; + function formatFileSize(bytes: number | null | undefined): string { - if (!bytes) return '' - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / 1024 / 1024).toFixed(1)} MB` + if (!bytes) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } function assignmentForSelectedDay( assignments: AssignmentsMap, selectedDayId: number | null, selectedAssignmentId: number | null, - placeId: number, + placeId: number ): Assignment | null { - if (selectedDayId == null) return null - const dayAssignments = assignments[String(selectedDayId)] || [] - return (selectedAssignmentId ? dayAssignments.find(assignment => assignment.id === selectedAssignmentId) : null) - ?? dayAssignments.find(assignment => assignment.place?.id === placeId) - ?? null + if (selectedDayId == null) return null; + const dayAssignments = assignments[String(selectedDayId)] || []; + return ( + (selectedAssignmentId ? dayAssignments.find((assignment) => assignment.id === selectedAssignmentId) : null) ?? + dayAssignments.find((assignment) => assignment.place?.id === placeId) ?? + null + ); +} + +type Translate = (key: string, params?: Record) => string; + +/** The parts of the time a tour takes, as fields: walking, breaks, the planned total. */ +function tourTimeFields(times: ReturnType, t: Translate): TourMetricField[] { + return [ + times.walkingMinutes != null + ? { label: t('tours.planner.walkingShort'), value: formatPlannedTourDuration(times.walkingMinutes) } + : null, + times.breakMinutes + ? { label: t('tours.planner.breaksShort'), value: formatPlannedTourDuration(times.breakMinutes) } + : null, + times.plannedTotalMinutes != null + ? { + label: times.manuallyOverridden + ? `${t('tours.planner.plannedShort')} (${t('tours.planner.plannedTotalManual')})` + : t('tours.planner.plannedShort'), + value: formatPlannedTourDuration(times.plannedTotalMinutes), + } + : null, + ].filter((field): field is TourMetricField => field !== null); } /** - * The card a tour opens over the map, in the place inspector's language: a head - * band tinted by the track colour with the tile, the name and the facts as - * pills, then each part of the tour under its own label, and the actions in a + * The head's facts as pills: the type, where it came from, the time (its parts + * in the tooltip), distance, ascent, descent, and a caution. + */ +function TourHeadPills({ + tour, + trackColor, + timeFields, + shownMinutes, + metrics, +}: { + tour: TourListItem; + trackColor: string; + timeFields: TourMetricField[]; + shownMinutes: number | null; + /** Measured from the line as it is now, so the head says what the fields below say. */ + metrics: { distanceKm: number | null; gain: number | null; loss: number | null }; +}) { + const { t } = useTranslation(); + const distanceUnit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + const timeTooltip = timeFields.map((field) => `${field.label}: ${field.value}`).join(', '); + const source = tourSource(tour); + // The import this tour was planned from, named while it is still in the trip. + const derivedFromName = useTripStore((state) => + tour.derived_from_place_id == null + ? null + : (state.places.find((p) => p.id === tour.derived_from_place_id)?.name ?? null) + ); + return ( +
+ + + {t(`tourTypes.${tour.tour_type}`)} + + {source !== 'trek' && ( + + + {hikeSourceBadgeLabel(tour, t)} + + )} + {derivedFromName && ( + + + + {t('tours.derivedFrom', { name: derivedFromName })} + + + )} + {shownMinutes != null && ( + + + + {formatPlannedTourDuration(shownMinutes)} + + + )} + {metrics.distanceKm != null && ( + + + {formatDistance(metrics.distanceKm, distanceUnit)} + + )} + {metrics.gain != null && ( + + + {formatElevation(metrics.gain, distanceUnit)} + + )} + {metrics.loss != null && ( + + + {formatElevation(metrics.loss, distanceUnit)} + + )} + {tour.caution && ( + + + }> + {t('tours.caution.badge')} + + + + )} +
+ ); +} + +/** + * The card a tour opens over the map, made like the place inspector: a head band + * tinted by the track colour with the grade tile, the name, what kind of tour it + * is and where it came from, its facts as pills; then the days it is on, its + * numbers with the elevation profile, its texts and files; the actions in a * footer that stays in reach. */ +/** + * The non-modal desktop dialog closes on Escape like a modal would, and gives + * focus back to what opened it. + */ +function useDesktopEscape(active: boolean, onClose: () => void, focusReturnTarget: HTMLElement | null) { + const closeRef = useRef(onClose); + closeRef.current = onClose; + const focusReturnRef = useRef(focusReturnTarget); + focusReturnRef.current = focusReturnTarget; + + useEffect(() => { + if (!active) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + event.preventDefault(); + closeRef.current(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + const opener = focusReturnRef.current; + if (opener?.isConnected) opener.focus(); + }; + }, [active]); +} + +/** Renaming the tour in place; `onUpdatePlace` is given only to someone who may change it. */ +function useTourNameEditor(place: Place, onUpdatePlace?: TourDetailDialogProps['onUpdatePlace']) { + const { t } = useTranslation(); + const toast = useToast(); + const [editingName, setEditingName] = useState(false); + const [nameDraft, setNameDraft] = useState(place.name); + const [savingName, setSavingName] = useState(false); + + const saveName = async () => { + const name = nameDraft.trim(); + if (!name || name === place.name || !onUpdatePlace) { + setEditingName(false); + return; + } + setSavingName(true); + try { + await onUpdatePlace(place.id, { name }); + setEditingName(false); + } catch (error: unknown) { + toast.error(translateApiError(t, error, 'common.error')); + } finally { + setSavingName(false); + } + }; + + return { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName }; +} + export default function TourDetailDialog({ tour, place, + days = [], selectedDayId = null, selectedAssignmentId = null, assignments = {}, @@ -95,91 +309,59 @@ export default function TourDetailDialog({ canEdit: editPermission, canAssign: assignPermission, }: TourDetailDialogProps) { - const { canEdit: hasEditPermission, canAssign } = useTourPermissions({ tripId: place.trip_id, canEdit: editPermission, canAssign: assignPermission }) - const canEdit = hasEditPermission && !readOnly - const { t } = useTranslation() - const toast = useToast() - const distanceUnit = useSettingsStore(state => state.settings.distance_unit) - const [editingName, setEditingName] = useState(false) - const [nameDraft, setNameDraft] = useState(place.name) - const [savingName, setSavingName] = useState(false) - const [filesExpanded, setFilesExpanded] = useState(false) - const [colorOpen, setColorOpen] = useState(false) - const [uploading, setUploading] = useState(false) - const [navigationOpen, setNavigationOpen] = useState(false) - const navigationAnchorRef = useRef(null) - const fileInputRef = useRef(null) - const closeRef = useRef(onClose) - closeRef.current = onClose - const focusReturnRef = useRef(desktopFocusReturnTarget) - focusReturnRef.current = desktopFocusReturnTarget + const { canEdit: hasEditPermission, canAssign } = useTourPermissions({ + tripId: place.trip_id, + canEdit: editPermission, + canAssign: assignPermission, + }); + const canEdit = hasEditPermission && !readOnly; + const { t } = useTranslation(); + const distanceUnit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + const [colorOpen, setColorOpen] = useState(false); + const [navigationOpen, setNavigationOpen] = useState(false); + const navigationAnchorRef = useRef(null); + const { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName } = useTourNameEditor( + place, + canEdit ? onUpdatePlace : undefined + ); + useDesktopEscape(desktopNonModal, onClose, desktopFocusReturnTarget); - useEffect(() => { - if (!desktopNonModal) return - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape' || event.defaultPrevented) return - event.preventDefault() - closeRef.current() - } - document.addEventListener('keydown', handleKeyDown) - return () => { - document.removeEventListener('keydown', handleKeyDown) - const opener = focusReturnRef.current - if (opener?.isConnected) opener.focus() - } - }, [desktopNonModal]) + const analysis = useMemo(() => analyzeRouteGeometry(place.route_geometry), [place.route_geometry]); + const trackColor = resolveTrackColor(place); + const inheritedColor = inheritedTrackColor(place); + const assignment = assignmentForSelectedDay(assignments, selectedDayId, selectedAssignmentId, place.id); + const tourDays = tourDaysOf(place.id, days, assignments); + const placeFiles = filesForPlace(files, place.id, [], assignment ? [assignment.id] : []); + const navigationTargets = getNavigationTargets(place); + const navigationLabel = + navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation'); + const description = tour.description !== undefined ? tour.description : place.description; + const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website); + const times = tourPlannedTimes(tour); - const analysis = useMemo(() => analyzeRouteGeometry(place.route_geometry), [place.route_geometry]) - const trackColor = resolveTrackColor(place) - const inheritedColor = inheritedTrackColor(place) - const assignment = assignmentForSelectedDay(assignments, selectedDayId, selectedAssignmentId, place.id) - const placeFiles = filesForPlace(files, place.id, [], assignment ? [assignment.id] : []) - const navigationTargets = getNavigationTargets(place) - const navigationLabel = navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation') - const source = tourSource(tour) - const description = tour.description !== undefined ? tour.description : place.description - const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website) - const colorLabel = place.route_color ? place.route_color.toUpperCase() : t('inspector.trackColorAuto') - - const saveName = async () => { - if (!canEdit) return - const name = nameDraft.trim() - if (!name || name === place.name || !onUpdatePlace) { setEditingName(false); return } - setSavingName(true) - try { - await onUpdatePlace(place.id, { name }) - setEditingName(false) - } catch (error: unknown) { - toast.error(translateApiError(t, error, 'common.error')) - } finally { - setSavingName(false) - } - } - - const uploadFiles = async (event: React.ChangeEvent) => { - const selectedFiles = Array.from(event.target.files || []) - event.target.value = '' - if (!canEdit || !onFileUpload || selectedFiles.length === 0) return - setUploading(true) - try { - for (const file of selectedFiles) { - const formData = new FormData() - formData.append('file', file) - formData.append('place_id', String(place.id)) - await onFileUpload(formData) - } - setFilesExpanded(true) - } catch (error: unknown) { - toast.error(translateApiError(t, error, 'files.uploadError')) - } finally { - setUploading(false) - } - } + const timeFields = tourTimeFields(times, t); const header = ( <> -
- +
+
+ +
+ {/* The track colour, picked where it shows: a dot on the tile, the picker below the head. */} + + +
{canEdit && editingName ? ( @@ -187,231 +369,344 @@ export default function TourDetailDialog({ setNameDraft(event.target.value)} - onKeyDown={event => { - if (event.key === 'Enter') void saveName() - if (event.key === 'Escape') { event.preventDefault(); setNameDraft(place.name); setEditingName(false) } + onChange={(event) => setNameDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void saveName(); + if (event.key === 'Escape') { + event.preventDefault(); + setNameDraft(place.name); + setEditingName(false); + } }} aria-label={t('places.formName')} className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none" style={fs(17, 'subtitle')} /> -
) : (
-

{place.name}

+

+ {place.name} +

{canEdit && onUpdatePlace && ( - )}
)} -
- }>{t(`tourTypes.${tour.tour_type}`)} - {tour.difficulty && {t('tours.detail.difficulty')}: {tour.difficulty}} - - {hikeSourceBadgeLabel(tour, t)} - {tour.caution && ( - - - }>{t('tours.caution.badge')} - - - )} -
+
- ) + ); - const footer = <> - {canAssign && selectedDayId != null && (assignment ? onRemoveAssignment && ( - { if (canAssign) onRemoveAssignment(selectedDayId, assignment.id) }} icon={} aria-label={t('inspector.removeFromDay')}> - {t('inspector.removeFromDay')} - - ) : ( - onAssignToDay && ( - { if (canAssign) onAssignToDay(place.id) }} icon={}> - {t('inspector.addToDay')} - - ) - ))} - {navigationTargets.length > 0 && ( - <> - - { - if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0]) - else setNavigationOpen(open => !open) - }} - aria-label={navigationLabel} - aria-haspopup={navigationTargets.length > 1 ? 'menu' : undefined} - aria-expanded={navigationTargets.length > 1 ? navigationOpen : undefined} - icon={} - > - {navigationLabel} - - - {navigationOpen && setNavigationOpen(false)} />} - - )} - - {canEdit && onDelete && { if (canEdit) onDelete() }} />} - + const footer = ( + <> + {canAssign && + selectedDayId != null && + (assignment + ? onRemoveAssignment && ( + { + if (canAssign) onRemoveAssignment(selectedDayId, assignment.id); + }} + icon={} + aria-label={t('inspector.removeFromDay')} + > + {t('inspector.removeFromDay')} + + ) + : onAssignToDay && ( + { + if (canAssign) onAssignToDay(place.id); + }} + icon={} + > + {t('inspector.addToDay')} + + ))} + {navigationTargets.length > 0 && ( + <> + + + { + if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0]); + else setNavigationOpen((open) => !open); + }} + aria-label={navigationLabel} + aria-haspopup={navigationTargets.length > 1 ? 'menu' : undefined} + aria-expanded={navigationTargets.length > 1 ? navigationOpen : undefined} + icon={} + > + {navigationLabel} + + + + {navigationOpen && ( + setNavigationOpen(false)} + /> + )} + + )} + {website && ( + + + window.open(website.href, '_blank', 'noopener,noreferrer')} + icon={} + aria-label={`${t('places.formWebsite')}: ${website.provider ?? website.domain}`} + > + {website.provider ?? t('places.formWebsite')} + + + + )} + + {canEdit && onDelete && ( + { + if (canEdit) onDelete(); + }} + /> + )} + + ); return ( - - {source === 'gpx' && ( - }> - {t('tours.detail.gpxReadOnly')} - {/* TODO(tours-roadmap): add the planned left-rail “Convert to TREK Tour” action here. */} - - )} - - {analysis && ( - -
- - {analysis.distanceIndexedProfileSamples.length >= 2 && ( -
- -
- )} + closeButtonClassName="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2" + > + {colorOpen && canEdit && onUpdatePlace && ( + +
+ { + if (canEdit) void onUpdatePlace(place.id, { route_color: color }); + }} + />
)} - - {canEdit && onUpdatePlace ? ( -
- - {colorOpen && ( -
- { if (canEdit) void onUpdatePlace(place.id, { route_color: color }) }} /> -
- )} + + {tourDays.length > 0 ? ( +
+
) : ( -
- - {colorLabel} -
+

+ {t('tours.days.none')} +

)}
- {description && {description}} - {website && ( - - - - {website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`} - - - )} - {place.notes && {place.notes}} - {assignment?.notes && {assignment.notes}} - - {(placeFiles.length > 0 || (canEdit && onFileUpload)) && ( - - - {uploading ? … : <>{t('common.upload')}} - + {analysis && ( +
+ + {timeFields.length > 0 && } + {analysis.distanceIndexedProfileSamples.length >= 2 && ( +
+ +
)} - > -
- - {filesExpanded && placeFiles.length > 0 && ( -
- {placeFiles.map(file => ( - - ))} -
- )} -
- - )} - - {navigationTargets.length > 0 && ( -
- - {t('tours.detail.navigationHint')}
)} + + {description && ( + + {description} + + )} + {place.notes && ( + + {place.notes} + + )} + {assignment?.notes && ( + + {assignment.notes} + + )} + + - ) + ); } -/** Walking time, breaks and the planned total, each as its own pill; missing values are skipped. */ -function TourTimePills({ tour }: { tour: TourListItem }) { - const { t } = useTranslation() - const tourTimes = tourPlannedTimes(tour) +/** A tour's files: the count to unfold, each file to open, and an upload for who may change the tour. */ +function TourFilesSection({ + placeId, + files, + onFileUpload, +}: { + placeId: number; + files: TripFile[]; + /** Given only to someone who may change the tour. */ + onFileUpload?: (formData: FormData) => Promise; +}) { + const { t } = useTranslation(); + const toast = useToast(); + const [filesExpanded, setFilesExpanded] = useState(false); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + const placeFiles = files; + + const uploadFiles = async (event: React.ChangeEvent) => { + const selectedFiles = Array.from(event.target.files || []); + event.target.value = ''; + if (!onFileUpload || selectedFiles.length === 0) return; + setUploading(true); + try { + for (const file of selectedFiles) { + const formData = new FormData(); + formData.append('file', file); + formData.append('place_id', String(placeId)); + await onFileUpload(formData); + } + setFilesExpanded(true); + } catch (error: unknown) { + toast.error(translateApiError(t, error, 'files.uploadError')); + } finally { + setUploading(false); + } + }; + + if (placeFiles.length === 0 && !onFileUpload) return null; return ( - <> - {tourTimes.walkingMinutes != null && ( - - - {t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)} + + + {uploading ? ( + … + ) : ( + <> + + {t('common.upload')} + + )} + + ) + } + > +
+ + {filesExpanded && placeFiles.length > 0 && ( +
+ {placeFiles.map((file) => ( + + ))} +
+ )} +
+
+ ); } /** Markdown in a framed box, like the inspector's; long words wrap instead of widening the card. */ function MarkdownBox({ children }: { children: string }) { return ( -
- {children} +
+ + {children} +
- ) + ); } diff --git a/client/src/components/Tours/TourGpxDropZone.test.tsx b/client/src/components/Tours/TourGpxDropZone.test.tsx new file mode 100644 index 000000000..514b2baa4 --- /dev/null +++ b/client/src/components/Tours/TourGpxDropZone.test.tsx @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../tests/helpers/render'; +import type { Day } from '../../types'; +import { dayMenuLabel, tourDaysOf } from './tourDays'; +import TourGpxDropZone from './TourGpxDropZone'; +import type { TourGpxImport } from './useTourGpxImport'; + +function gpxImport(overrides: Partial = {}): TourGpxImport { + return { importing: false, importFiles: vi.fn().mockResolvedValue(undefined), pick: vi.fn(), ...overrides }; +} + +const file = new File([''], 'ridge.gpx', { type: 'application/gpx+xml' }); +const filesDrag = { dataTransfer: { types: ['Files'], files: [file], dropEffect: 'none' } }; +const rowDrag = { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' } }; + +describe('TourGpxDropZone', () => { + it('RC-04: a file dragged over the workspace says it will be imported, and a drop imports it', () => { + const importer = gpxImport(); + render( + +

workspace

+
+ ); + const zone = screen.getByText('workspace').parentElement as HTMLElement; + + fireEvent.dragEnter(zone, filesDrag); + expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Drop GPX files to import them'); + + fireEvent.drop(zone, filesDrag); + expect(importer.importFiles).toHaveBeenCalledWith([file]); + expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument(); + }); + + it('RC-04: a waypoint or tour row being dragged passes straight through', () => { + const importer = gpxImport(); + render( + +

workspace

+
+ ); + const zone = screen.getByText('workspace').parentElement as HTMLElement; + + fireEvent.dragEnter(zone, rowDrag); + fireEvent.drop(zone, rowDrag); + expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument(); + expect(importer.importFiles).not.toHaveBeenCalled(); + }); + + it('RC-04: leaving every child it entered takes the overlay away again', () => { + render( + +

workspace

+
+ ); + const zone = screen.getByText('workspace').parentElement as HTMLElement; + fireEvent.dragEnter(zone, filesDrag); + fireEvent.dragEnter(zone, filesDrag); + fireEvent.dragLeave(zone, filesDrag); + expect(screen.getByTestId('tour-gpx-drop')).toBeInTheDocument(); + fireEvent.dragLeave(zone, filesDrag); + expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument(); + }); + + it('RC-04: without the right to add tours it says so and imports nothing', () => { + const importer = gpxImport(); + render( + +

workspace

+
+ ); + const zone = screen.getByText('workspace').parentElement as HTMLElement; + fireEvent.dragEnter(zone, filesDrag); + expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('You cannot add tours to this trip.'); + fireEvent.drop(zone, filesDrag); + expect(importer.importFiles).not.toHaveBeenCalled(); + }); + + it('RC-04: stays up and says so while an import runs', () => { + render( + +

workspace

+
+ ); + expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Importing GPX…'); + }); +}); + +describe('tourDays', () => { + const days = [ + { id: 1, title: null, date: '2026-07-08' }, + { id: 2, title: 'Lakes', date: null }, + { id: 3, title: null, date: null }, + ] as Day[]; + + it('RC-02: lists the days a tour is on in the order of the trip, each once', () => { + const assignments = { + '3': [{ id: 9, place_id: 42 }], + '1': [ + { id: 7, place_id: 42 }, + { id: 8, place_id: 42 }, + ], + '2': [{ id: 6, place_id: 5 }], + } as never; + expect(tourDaysOf(42, days, assignments).map(({ day, index }) => [day.id, index])).toEqual([ + [1, 0], + [3, 2], + ]); + expect(tourDaysOf(99, days, assignments)).toEqual([]); + }); + + it('RC-02: names a day by its number, its date and its title as far as it has them', () => { + const t = (key: string, params?: Record) => (key === 'dayplan.dayN' ? `Day ${params?.n}` : key); + expect(dayMenuLabel(days[1], 1, t, 'en')).toBe('Day 2: Lakes'); + expect(dayMenuLabel(days[2], 2, t, 'en')).toBe('Day 3'); + expect(dayMenuLabel(days[0], 0, t, 'en')).toMatch(/^Day 1, .*8/); + }); +}); diff --git a/client/src/components/Tours/TourGpxDropZone.tsx b/client/src/components/Tours/TourGpxDropZone.tsx new file mode 100644 index 000000000..c9c4bff3c --- /dev/null +++ b/client/src/components/Tours/TourGpxDropZone.tsx @@ -0,0 +1,99 @@ +import { Ban, FileDown, Loader2 } from 'lucide-react'; +import { useRef, useState, type DragEvent, type ReactNode } from 'react'; +import { useTranslation } from '../../i18n'; +import { fs } from '../shared/DialogShell'; +import type { TourGpxImport } from './useTourGpxImport'; + +/** Only a drag of files from the desktop; a waypoint or a tour row being dragged is not one. */ +const carriesFiles = (event: DragEvent) => Array.from(event.dataTransfer.types).includes('Files'); + +/** + * The tours workspace as a drop target for GPX files: a file dragged over it + * dims the workspace and says what will happen, and a drop hands the files to + * the same import the button runs. While an import runs, the overlay stays and + * says so. A drag of anything else (a waypoint in the list, the map) passes + * straight through. + */ +export default function TourGpxDropZone({ + gpxImport, + canEdit, + className = 'relative', + children, +}: { + gpxImport: TourGpxImport; + canEdit: boolean; + /** Positions the zone; it must be positioned for the overlay to cover it. */ + className?: string; + children: ReactNode; +}) { + const { t } = useTranslation(); + const [over, setOver] = useState(false); + // dragenter and dragleave fire for every child the pointer crosses, so the + // overlay goes only when the count of entered elements is back at zero. + const depth = useRef(0); + + const reset = () => { + depth.current = 0; + setOver(false); + }; + + return ( +
{ + if (!carriesFiles(event)) return; + event.preventDefault(); + depth.current += 1; + setOver(true); + }} + onDragOver={(event) => { + if (!carriesFiles(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = canEdit && !gpxImport.importing ? 'copy' : 'none'; + }} + onDragLeave={(event) => { + if (!carriesFiles(event)) return; + depth.current = Math.max(0, depth.current - 1); + if (depth.current === 0) setOver(false); + }} + onDrop={(event) => { + if (!carriesFiles(event)) return; + event.preventDefault(); + reset(); + if (canEdit) void gpxImport.importFiles(Array.from(event.dataTransfer.files)); + }} + > + {children} + {(over || gpxImport.importing) && ( +
+ + {gpxImport.importing ? ( + + ) : canEdit ? ( + + ) : ( + + )} + + + {gpxImport.importing + ? t('tours.import.importing') + : canEdit + ? t('tours.import.drop') + : t('tours.import.dropNoPermission')} + + {!gpxImport.importing && canEdit && ( + + {t('tours.import.dropHint')} + + )} +
+ )} +
+ ); +} diff --git a/client/src/components/Tours/TourListRow.tsx b/client/src/components/Tours/TourListRow.tsx index ab62b322d..b22db93df 100644 --- a/client/src/components/Tours/TourListRow.tsx +++ b/client/src/components/Tours/TourListRow.tsx @@ -1,25 +1,42 @@ import { useId, type ReactNode } from 'react' import type { TourListItem } from '@trek/shared' -import { Link2 } from 'lucide-react' import { fs } from '../shared/DialogShell' -import { SoftPill } from '../Planner/planParts' -import { TourFactPills, TourGradeTile } from './tourParts' -import { tourWebsitePresentation } from './tourPresentation' +import { TourCautionBadge, TourDaysBadge, TourGradeTile } from './tourParts' +import type { Day } from '../../types' +import { endPlaceDrag, startPlaceDrag } from '../Map/markerDrag' +import { TourDayRowPills } from './TourDayRowFacts' +import { useTourDays } from './tourDays' + +const NO_DAYS: Day[] = [] interface TourListRowProps { tour: TourListItem + /** The trip's days, to name the ones the tour is on; without them the row names none. */ + days?: Day[] selected?: boolean disabled?: boolean + /** The row can be picked up and dropped on a day of the plan, like a place. */ + draggableToDay?: boolean onSelect: (tour: TourListItem, opener: HTMLElement) => void action?: ReactNode children?: ReactNode } -/** A tour in a list, shaped like a place row: tile, name, its facts as pills and the day action on the right. */ -export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) { +/** + * A tour in a list, laid out like a stop in the day plan: its tile with the + * grade, the name, and one line of the plan's white badges (time, distance, + * ascent, the days it is on, a caution) with the description trailing after + * them. The day and delete actions sit on the right. + */ +export default function TourListRow({ tour, + days = NO_DAYS, + selected = false, disabled = false, + draggableToDay = false, onSelect, action, children }: TourListRowProps) { const nameId = useId() + const tourDays = useTourDays(tour.place_id, days) + const draggable = draggableToDay && !disabled const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover' - const website = tourWebsitePresentation(tour.website) + const cursor = disabled ? 'cursor-default opacity-60' : draggable ? 'cursor-grab' : 'cursor-pointer' return (
  • startPlaceDrag(event.dataTransfer, tour.place_id) : undefined} + onDragEnd={draggable ? endPlaceDrag : undefined} + className={`group mx-1.5 rounded-[10px] px-1.5 py-2 outline-none transition-colors focus-visible:bg-surface-hover ${tone} ${cursor}`} onClick={event => { if (!disabled) onSelect(tour, event.currentTarget) }} onKeyDown={event => { if (disabled || event.target !== event.currentTarget) return @@ -37,31 +57,39 @@ export default function TourListRow({ tour, selected = false, disabled = false, } }} > - {/* The tile, the name and the day actions share the head line; the rest runs the - full width under them, below the tile too. */}
    - {tour.name} +
    + {tour.name} + {/* One line like a plan row's: the badges keep their place, the description gives way. */} +
    + + + + {tour.description && ( + + {tour.description} + + )} +
    +
    {action && ( -
    event.stopPropagation()}> + // Out on hover, focus or selection, as a plan row's controls are: the name and badges have the room otherwise. +
    event.stopPropagation()} + className={`flex-none items-center gap-1.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}> {action}
    )}
    - {tour.description && ( -
    - - {tour.description} - -
    - )} - - {website && ( - }> - {website.provider ?? website.domain} - - )} - {children}
  • ) diff --git a/client/src/components/Tours/TourWorkspaceRail.test.tsx b/client/src/components/Tours/TourWorkspaceRail.test.tsx new file mode 100644 index 000000000..e30e40810 --- /dev/null +++ b/client/src/components/Tours/TourWorkspaceRail.test.tsx @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '../../../tests/helpers/render'; +import TourWorkspaceRail from './TourWorkspaceRail'; +import type { GpxConversion } from './conversion/useGpxConversion'; +import type { TourPlannerController } from './planner/useTourPlanner'; +import type { RecordedTrack } from './useRecordedTrack'; +import type { WandererBrowser } from './wanderer/useWandererBrowser'; + +const seen = vi.hoisted(() => ({ rail: [] as Array> })); +vi.mock('./planner/TourPlannerPanels', () => ({ + TourPlannerRail: (props: Record) => { + seen.rail.push(props); + return

    tour editor

    ; + }, +})); +vi.mock('./wanderer/WandererBrowserRail', () => ({ default: () =>

    wanderer trails

    })); + +const planner = {} as TourPlannerController; +const conversion = {} as GpxConversion; +const recordedTrack = {} as RecordedTrack; + +function wanderer(overrides: Partial): WandererBrowser { + return { open: false, enabled: true, openBrowser: vi.fn(), ...overrides } as unknown as WandererBrowser; +} + +describe('TourWorkspaceRail', () => { + it('RS-04: shows the wanderer trails in place of the editor while they are browsed', () => { + render( + + ); + expect(screen.getByText('wanderer trails')).toBeInTheDocument(); + expect(screen.queryByText('tour editor')).not.toBeInTheDocument(); + }); + + it('RS-04: offers the wanderer trails from the editor only while wanderer may be used', () => { + const browser = wanderer({}); + seen.rail.length = 0; + const view = render( + + ); + expect(screen.getByText('tour editor')).toBeInTheDocument(); + expect(seen.rail[seen.rail.length - 1]).toMatchObject({ + planner, + conversion, + recordedTrack, + canEdit: true, + canAssign: false, + onOpenWanderer: browser.openBrowser, + }); + + view.rerender( + + ); + expect(seen.rail[seen.rail.length - 1]?.onOpenWanderer).toBeUndefined(); + }); +}); diff --git a/client/src/components/Tours/TourWorkspaceRail.tsx b/client/src/components/Tours/TourWorkspaceRail.tsx new file mode 100644 index 000000000..a516c3d75 --- /dev/null +++ b/client/src/components/Tours/TourWorkspaceRail.tsx @@ -0,0 +1,36 @@ +import type { GpxConversion } from './conversion/useGpxConversion'; +import { TourPlannerRail } from './planner/TourPlannerPanels'; +import type { TourPlannerController } from './planner/useTourPlanner'; +import type { RecordedTrack } from './useRecordedTrack'; +import WandererBrowserRail from './wanderer/WandererBrowserRail'; +import type { WandererBrowser } from './wanderer/useWandererBrowser'; + +/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */ +export default function TourWorkspaceRail({ + planner, + wanderer, + conversion, + recordedTrack, + canEdit, + canAssign, +}: { + planner: TourPlannerController; + wanderer: WandererBrowser; + conversion: GpxConversion; + recordedTrack: RecordedTrack; + canEdit: boolean; + canAssign: boolean; +}) { + return wanderer.open ? ( + + ) : ( + + ); +} diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx index 0f8448c19..42cca4a63 100644 --- a/client/src/components/Tours/ToursSidebar.test.tsx +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -6,6 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { useTripStore } from '../../store/tripStore' import { toursApi } from '../../api/client' import ToursSidebar from './ToursSidebar' +import { pickFiles } from '../../../tests/helpers/filePicker' const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })) @@ -32,6 +33,8 @@ const tour: TourListItem = { } beforeEach(() => { + vi.restoreAllMocks() + Object.values(toast).forEach((notify) => notify.mockClear()) resetAllStores() seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } }) }) @@ -56,15 +59,17 @@ describe('ToursSidebar', () => { expect(row).toHaveClass('bg-surface-selected') }) - it('RS-01: previews the Tour description and classified information-link host', () => { + it('RS-01: trails the description after the badges on one line, as a plan row does', () => { const listed = { ...tour, description: 'A quiet path above the lake', website: 'https://www.komoot.com/tour/42', planned_duration_minutes: 95 } render() const row = screen.getByRole('option', { name: /Selected ridge walk/i }) - expect(row).toHaveTextContent('A quiet path above the lake') - expect(row).toHaveTextContent('Komoot') - expect(row).not.toHaveTextContent('komoot.com') - expect(row).toHaveTextContent('1 h 35 min') - expect(row.querySelector('[data-testid="tour-description-preview"] > span')).toHaveStyle({ WebkitLineClamp: '2' }) + const line = within(row).getByTestId('tour-metrics') + expect(line).toHaveClass('flex-nowrap', 'overflow-hidden') + expect(line).toHaveTextContent('1 h 35 min') + expect(within(line).getByTestId('tour-description-preview')).toHaveTextContent('A quiet path above the lake') + expect(within(line).getByTestId('tour-description-preview')).toHaveClass('truncate') + // The information link belongs to the detail, not the list row. + expect(row).not.toHaveTextContent('Komoot') }) it('selects the tour from keyboard activation', () => { @@ -98,11 +103,9 @@ describe('ToursSidebar', () => { const metrics = row.querySelector('[data-testid="tour-metrics"]')! expect(metrics).toHaveTextContent('1.9 km') expect(metrics).toHaveTextContent('13 m') - expect(metrics).toHaveTextContent('11 m') const labels = [...metrics.querySelectorAll('[aria-label]')].map(element => element.getAttribute('aria-label')) expect(labels[0]).toContain('1.9 km') expect(labels[1]).toContain('13 m') - expect(labels[2]).toContain('11 m') expect(within(row).queryByText('TREK')).not.toBeInTheDocument() expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument() const actionRow = row.querySelector('[data-testid="tour-action-row"]')! @@ -205,7 +208,9 @@ describe('ToursSidebar', () => { }) const onToursChanged = vi.fn() const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined) - const { container } = render( { onSelectTour={vi.fn()} />) - const input = container.querySelector('input[type="file"]')! const file = new File([''], 'ridge.gpx', { type: 'application/gpx+xml' }) - fireEvent.change(input, { target: { files: [file] } }) + expect(pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])).toHaveAttribute('accept', '.gpx') - await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1)) + // Only the places are read again, not the whole trip: that would rebuild the workspace. + await waitFor(() => expect(refreshPlaces).toHaveBeenCalledWith(1)) + expect(loadTrip).not.toHaveBeenCalled() expect(onToursChanged).toHaveBeenCalledWith([42]) - expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(loadTrip.mock.invocationCallOrder[0]) + expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(refreshPlaces.mock.invocationCallOrder[0]) expect(importCall).toHaveBeenCalledOnce() vi.restoreAllMocks() }) @@ -230,7 +236,7 @@ describe('ToursSidebar', () => { vi.spyOn(toursApi, 'importGpx').mockResolvedValue({ tours: [], caution: false, skipped: 3 }) const onToursChanged = vi.fn() const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined) - const { container } = render( { onSelectTour={vi.fn()} />) - fireEvent.change(container.querySelector('input[type=file]')!, { - target: { files: [new File([''], 'already-imported.gpx', { type: 'application/gpx+xml' })] }, - }) + pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File([''], 'already-imported.gpx', { type: 'application/gpx+xml' }), + ]) - await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('All places were already in the trip.')) + await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('Everything in that file is already in this trip.')) expect(onToursChanged).not.toHaveBeenCalled() expect(loadTrip).not.toHaveBeenCalled() vi.restoreAllMocks() }) + it('RC-04: names each file that fails and still imports the rest', async () => { + vi.spyOn(toursApi, 'importGpx') + .mockRejectedValueOnce({ response: { data: { error: 'No track or route found in GPX file' } } }) + .mockResolvedValueOnce({ tours: [tour], caution: true, skipped: 0 }) + seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) }) + render( + + ) + + pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [ + new File([''], 'empty.gpx', { type: 'application/gpx+xml' }), + new File([''], 'ridge.gpx', { type: 'application/gpx+xml' }), + ]) + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported from GPX')) + expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx') + expect(toast.info).toHaveBeenCalledWith('Imported with caution — elevation/metrics incomplete') + vi.restoreAllMocks() + }) + + it('RC-04: refuses files that are not GPX before uploading anything', async () => { + const importCall = vi.spyOn(toursApi, 'importGpx') + render( + + ) + + pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [ + new File(['x'], 'notes.txt', { type: 'text/plain' }), + ]) + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Only GPX files can be imported as tours.')) + expect(importCall).not.toHaveBeenCalled() + vi.restoreAllMocks() + }) + + it('RC-02: names the days a tour is on in one badge, in the order of the trip', () => { + const days = [ + { id: 7, trip_id: 1, day_number: 1, title: null, date: null }, + { id: 8, trip_id: 1, day_number: 2, title: null, date: null }, + { id: 9, trip_id: 1, day_number: 3, title: 'Summit', date: null }, + ] as never[] + seedStore(useTripStore, { + assignments: { '9': [{ id: 2, place_id: 42 }], '7': [{ id: 1, place_id: 42 }] } as never, + }) + render() + + const badge = within(screen.getByRole('option', { name: /Selected ridge walk/ })).getByTestId('tour-days-badge') + expect(badge).toHaveTextContent('Days 1, 3') + expect(badge).toHaveAttribute('aria-label', 'Day 1, Day 3: Summit') + }) }) diff --git a/client/src/components/Tours/ToursSidebar.tsx b/client/src/components/Tours/ToursSidebar.tsx index aba251dde..657c415ab 100644 --- a/client/src/components/Tours/ToursSidebar.tsx +++ b/client/src/components/Tours/ToursSidebar.tsx @@ -2,10 +2,7 @@ import React, { useEffect, useRef, useState } from 'react' import { Check, ChevronDown, FileDown } from 'lucide-react' import type { TourListItem } from '@trek/shared' import type { Day } from '../../types' -import { tourRepo } from '../../repo/tourRepo' -import { useTripStore } from '../../store/tripStore' import { useTranslation } from '../../i18n' -import { useToast } from '../shared/Toast' import TourListRow from './TourListRow' import { useTourPermissions, type TourPermissionProps } from './useTourPermissions' import { NEUTRAL_TINT, fs } from '../shared/DialogShell' @@ -13,8 +10,7 @@ import { Tooltip } from '../shared/Tooltip' import EmptyState from '../shared/EmptyState' import { TourDayMenu } from './tourParts' import { filterTours, type TourFilter } from './tourPresentation' - -const MAX_FILE_BYTES = 10 * 1024 * 1024 +import { useTourGpxImport } from './useTourGpxImport' interface ToursSidebarProps extends TourPermissionProps { tripId: number @@ -43,52 +39,14 @@ interface ToursSidebarProps extends TourPermissionProps { export default function ToursSidebar({ tripId, days, tours, loading = false, selectedPlaceId = null, onAssignToDay, onToursChanged, onSelectTour, canEdit: editPermission, canAssign: assignPermission }: ToursSidebarProps): React.ReactElement { const { t } = useTranslation() const { canEdit, canAssign } = useTourPermissions({ tripId, canEdit: editPermission, canAssign: assignPermission }) - const toast = useToast() - const loadTrip = useTripStore(s => s.loadTrip) + const gpxImport = useTourGpxImport({ tripId, canEdit, onToursChanged }) const [filter, setFilter] = useState('all') - const [importing, setImporting] = useState(false) - const fileInputRef = useRef(null) const filterRef = useRef(null) const filterButtonRef = useRef(null) const [filterOpen, setFilterOpen] = useState(false) - const handleImportClick = () => { if (canEdit) fileInputRef.current?.click() } - - const handleFileChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - e.target.value = '' - if (!canEdit || !file) return - const ext = file.name.toLowerCase().split('.').pop() - if (ext !== 'gpx') { toast.error(t('places.importFileUnsupported')); return } - if (file.size > MAX_FILE_BYTES) { toast.error(t('places.importFileTooLarge', { maxMb: 10 })); return } - - setImporting(true) - try { - const result = await tourRepo.importGpx(tripId, file) - if (result.tours.length > 0) { - // The server publishes place:created with sender echo suppression. - // Reload the importing client's trip so new places and route geometry - // enter the shared store used by the day map. - await onToursChanged?.(result.tours.map(tour => tour.place_id)) - await loadTrip(tripId) - toast.success(t('tours.import.success', { count: result.tours.length })) - if (result.caution) toast.info(t('tours.import.caution')) - } else { - toast.warning(t('places.importAllSkipped')) - } - } catch (err: any) { - const message = err?.response?.data?.error - toast.error(message === 'No track or route found in GPX file' ? t('tours.import.noTrack') : t('tours.import.error')) - } finally { - setImporting(false) - } - } - - const handleAssign = async (tour: TourListItem, day: Day, index: number) => { - if (!canAssign) return - const assigned = await onAssignToDay(tour.place_id, day.id) - if (assigned === false) return - toast.success(t('tours.addedToDay', { n: index + 1 })) + const handleAssign = (tour: TourListItem, day: Day) => { + if (canAssign) void onAssignToDay(tour.place_id, day.id) } const counts = { @@ -126,15 +84,14 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel {/* GPX import stays — the tour-appropriate import path. List Import (place/route-of-driving oriented) is deliberately absent here. */}
    - - -
    +
    + ); + } + + const { comparison } = conversion; + const faithful = comparison.withinCorridor >= FAITHFUL_SHARE; + const facts: TourMetricField[] = [ + { label: t('tours.conversion.importLength'), value: formatDistance(comparison.originalKm, unit) }, + { label: t('tours.conversion.suggestionLength'), value: formatDistance(comparison.suggestionKm, unit) }, + { + label: t('tours.conversion.withinCorridor', { m: CORRIDOR_M }), + value: `${Math.round(comparison.withinCorridor * 100)} %`, + }, + { label: t('tours.conversion.maxDeviation'), value: `${Math.round(comparison.maxDeviationM)} m` }, + { label: t('tours.conversion.controlPoints'), value: String(conversion.points.length) }, + ]; + return card( +
    + + + + ) : ( + + ) + } + > + {t(faithful ? 'tours.conversion.close' : 'tours.conversion.different')} + +
    + + + adopt('replace')} + icon={} + > + {t('tours.conversion.replace')} + + + +
    + + + adopt('new')} icon={}> + {t('tours.conversion.adopt')} + + + + }> + {t('tours.conversion.discard')} + +
    +
    +
    + ); +} diff --git a/client/src/components/Tours/conversion/gpxConversion.test.ts b/client/src/components/Tours/conversion/gpxConversion.test.ts new file mode 100644 index 000000000..585cf421e --- /dev/null +++ b/client/src/components/Tours/conversion/gpxConversion.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { CORRIDOR_M, compareRoutes, deriveControlPoints, lineLengthMeters, type TrackPoint } from './gpxConversion'; + +/** About 111 m per 0.001 degree of latitude. */ +const north = (lat: number, lng = 11): TrackPoint => ({ lat, lng }); + +/** A straight line north, one point every ~11 m, `km` long. */ +function straightTrack(km: number): TrackPoint[] { + const steps = Math.round(km * 90); + return Array.from({ length: steps + 1 }, (_, i) => north(47 + (i * km) / steps / 111.2)); +} + +/** An L: north for `legKm`, then east for `legKm`. */ +function bentTrack(legKm: number): TrackPoint[] { + const up = straightTrack(legKm); + const corner = up[up.length - 1]; + const east = Array.from({ length: 90 }, (_, i) => ({ + lat: corner.lat, + lng: corner.lng + ((i + 1) * legKm) / 90 / 75.8, + })); + return [...up, ...east]; +} + +describe('deriveControlPoints', () => { + it('PR1-05A: keeps start and end and nothing else on a short straight track', () => { + const track = straightTrack(1); + const points = deriveControlPoints(track); + expect(points).toEqual([track[0], track[track.length - 1]]); + }); + + it('PR1-05A: keeps the bend a track turns at', () => { + const track = bentTrack(1); + const points = deriveControlPoints(track); + expect(points.length).toBe(3); + const corner = track[90]; + expect(points[1].lat).toBeCloseTo(corner.lat, 3); + expect(points[1].lng).toBeCloseTo(corner.lng, 3); + }); + + it('PR1-05A: adds a point wherever a track runs on longer than the longest gap', () => { + const track = straightTrack(10); + const points = deriveControlPoints(track, { maxGapM: 2_500 }); + expect(points.length).toBeGreaterThanOrEqual(5); + for (let i = 1; i < points.length; i += 1) { + expect(lineLengthMeters([points[i - 1], points[i]])).toBeLessThanOrEqual(2_600); + } + }); + + it('PR1-05A: never keeps more points than asked for, start and end included', () => { + // A zigzag every point of which is a bend. + const zigzag = Array.from({ length: 400 }, (_, i) => ({ lat: 47 + i * 0.0009, lng: 11 + (i % 2) * 0.002 })); + const points = deriveControlPoints(zigzag, { maxPoints: 12 }); + expect(points.length).toBeLessThanOrEqual(12); + expect(points[0]).toEqual(zigzag[0]); + expect(points[points.length - 1]).toEqual(zigzag[zigzag.length - 1]); + }); + + it('PR1-05A: merges points closer than the minimum spacing, keeping start and end', () => { + const track = [north(47), north(47.0002), north(47.0004), north(47.02)]; + const points = deriveControlPoints(track, { minSpacingM: 150 }); + expect(points[0]).toEqual(track[0]); + expect(points[points.length - 1]).toEqual(track[3]); + expect(points).not.toContainEqual(track[1]); + }); + + it('PR1-05A: has nothing to offer for a track of fewer than two points', () => { + expect(deriveControlPoints([])).toEqual([]); + expect(deriveControlPoints([north(47)])).toEqual([]); + }); +}); + +describe('compareRoutes', () => { + it('PR1-05A: a suggestion on the track itself lies wholly in the corridor', () => { + const track = straightTrack(2); + const result = compareRoutes(track, [track[0], track[track.length - 1]]); + expect(result.withinCorridor).toBe(1); + expect(result.maxDeviationM).toBeLessThan(1); + expect(result.originalKm).toBeCloseTo(2, 1); + expect(result.suggestionKm).toBeCloseTo(2, 1); + }); + + it('PR1-05A: a suggestion that cuts a corner says how far it strays and for how much of the track', () => { + const track = bentTrack(1); + const shortcut = [track[0], track[track.length - 1]]; + const result = compareRoutes(track, shortcut); + expect(result.maxDeviationM).toBeGreaterThan(500); + expect(result.withinCorridor).toBeLessThan(0.5); + expect(result.suggestionKm).toBeLessThan(result.originalKm); + }); + + it('PR1-05A: counts a stray of exactly the corridor width as followed', () => { + const track = [north(47), north(47.001)]; + const offset = CORRIDOR_M / 75_800; // degrees of longitude for the corridor width at 47° N + const beside = track.map((point) => ({ lat: point.lat, lng: point.lng + offset * 0.99 })); + expect(compareRoutes(track, beside).withinCorridor).toBe(1); + }); + + it('PR1-05A: compares a one-point suggestion by plain distance', () => { + const result = compareRoutes([north(47), north(47.001)], [north(47)]); + expect(result.maxDeviationM).toBeGreaterThan(100); + }); +}); diff --git a/client/src/components/Tours/conversion/gpxConversion.ts b/client/src/components/Tours/conversion/gpxConversion.ts new file mode 100644 index 000000000..381266a63 --- /dev/null +++ b/client/src/components/Tours/conversion/gpxConversion.ts @@ -0,0 +1,188 @@ +/** + * Turning an imported track into control points a tour can be planned through, + * and measuring how far the line routed through them strays from the track. + * + * A recorded track is thousands of points, most of them noise of the walk, and + * none of them says where the walker meant to go. The points kept here are the + * ones the track's shape needs: a Douglas-Peucker pass keeps the bends, and a + * longest-gap rule adds a point wherever the track runs on too long without + * one, so the router is not left to guess across kilometres. Nothing here + * claims the result is the same tour; the comparison says how close it is. + */ + +export interface TrackPoint { + lat: number; + lng: number; +} + +export interface ControlPointOptions { + /** The most control points to keep, start and end included. */ + maxPoints?: number; + /** The longest stretch of track, in metres, left without a control point. */ + maxGapM?: number; + /** Control points closer than this, in metres, are merged (start and end stay). */ + minSpacingM?: number; +} + +export interface RouteComparison { + /** Length of the imported track, in kilometres. */ + originalKm: number; + /** Length of the routed suggestion, in kilometres. */ + suggestionKm: number; + /** The farthest any point of the track lies from the suggestion, in metres. */ + maxDeviationM: number; + /** The share of the track within {@link CORRIDOR_M} of the suggestion, from 0 to 1. */ + withinCorridor: number; +} + +/** How far from the suggestion a stretch of track still counts as followed, in metres. */ +export const CORRIDOR_M = 50; +/** Below this share of the track in the corridor the suggestion is called a different route. */ +export const FAITHFUL_SHARE = 0.85; + +const EARTH_M = 6_371_000; +const RAD = Math.PI / 180; +/** The track's points compared against the suggestion, at most; a long track is sampled evenly. */ +const COMPARE_SAMPLES = 600; + +/** Points on a local plane in metres, good enough over the span of one tour. */ +function projector(points: readonly TrackPoint[]) { + const meanLat = points.reduce((sum, p) => sum + p.lat, 0) / Math.max(points.length, 1); + const kx = Math.cos(meanLat * RAD) * EARTH_M * RAD; + const ky = EARTH_M * RAD; + return (p: TrackPoint): [number, number] => [p.lng * kx, p.lat * ky]; +} + +function distanceToSegment(p: [number, number], a: [number, number], b: [number, number]): number { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const length2 = dx * dx + dy * dy; + const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length2)); + return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy)); +} + +/** The length of a line, in metres. */ +export function lineLengthMeters(points: readonly TrackPoint[]): number { + const project = projector(points); + let total = 0; + for (let i = 1; i < points.length; i += 1) { + const [ax, ay] = project(points[i - 1]); + const [bx, by] = project(points[i]); + total += Math.hypot(bx - ax, by - ay); + } + return total; +} + +/** Douglas-Peucker on projected points: the indices a line of the given tolerance keeps. */ +function simplify(xy: readonly [number, number][], tolerance: number): number[] { + const keep = new Uint8Array(xy.length); + keep[0] = 1; + keep[xy.length - 1] = 1; + const stack: Array<[number, number]> = [[0, xy.length - 1]]; + for (let range = stack.pop(); range; range = stack.pop()) { + const [first, last] = range; + let farthest = -1; + let farthestDistance = tolerance; + for (let i = first + 1; i < last; i += 1) { + const distance = distanceToSegment(xy[i], xy[first], xy[last]); + if (distance > farthestDistance) { + farthest = i; + farthestDistance = distance; + } + } + if (farthest !== -1) { + keep[farthest] = 1; + stack.push([first, farthest], [farthest, last]); + } + } + return xy.flatMap((_, index) => (keep[index] ? [index] : [])); +} + +/** Adds a kept index wherever the track runs longer than `maxGapM` between two kept ones. */ +function fillGaps(kept: number[], along: readonly number[], maxGapM: number): number[] { + const result: number[] = [kept[0]]; + for (let k = 1; k < kept.length; k += 1) { + const from = kept[k - 1]; + const to = kept[k]; + let last = from; + for (let i = from + 1; i < to; i += 1) { + if (along[i] - along[last] >= maxGapM && along[to] - along[i] >= maxGapM / 4) { + result.push(i); + last = i; + } + } + result.push(to); + } + return result; +} + +/** Drops kept indices closer than `minSpacingM` along the track to the one before; the end always stays. */ +function spaceOut(kept: number[], along: readonly number[], minSpacingM: number): number[] { + const result: number[] = [kept[0]]; + for (let k = 1; k < kept.length - 1; k += 1) { + if (along[kept[k]] - along[result[result.length - 1]] >= minSpacingM) result.push(kept[k]); + } + const end = kept[kept.length - 1]; + if (result.length > 1 && along[end] - along[result[result.length - 1]] < minSpacingM) result.pop(); + result.push(end); + return result; +} + +/** + * The control points a tour planned from this track would run through: start + * and end, the bends the track's shape needs, and a point wherever it runs on + * longer than `maxGapM` without one. A tolerance that keeps too many is widened + * until the points fit `maxPoints`. A track with fewer than two points has none. + */ +export function deriveControlPoints(track: readonly TrackPoint[], options: ControlPointOptions = {}): TrackPoint[] { + const { maxPoints = 25, maxGapM = 2_500, minSpacingM = 150 } = options; + if (track.length < 2) return []; + const project = projector(track); + const xy = track.map(project); + const along = [0]; + for (let i = 1; i < xy.length; i += 1) + along.push(along[i - 1] + Math.hypot(xy[i][0] - xy[i - 1][0], xy[i][1] - xy[i - 1][1])); + let tolerance = 25; + let kept: number[] = []; + for (let round = 0; round < 24; round += 1) { + kept = spaceOut(fillGaps(simplify(xy, tolerance), along, maxGapM), along, minSpacingM); + if (kept.length <= maxPoints) break; + tolerance *= 1.4; + } + if (kept.length > maxPoints) { + // Even a wide tolerance keeps too many (a very long track): evenly along it instead. + const total = along[along.length - 1]; + kept = Array.from({ length: maxPoints }, (_, n) => { + const target = (total * n) / (maxPoints - 1); + return Math.max( + 0, + along.findIndex((distance) => distance >= target) + ); + }); + } + return kept.map((index) => ({ lat: track[index].lat, lng: track[index].lng })); +} + +/** How closely the suggestion follows the track: lengths, the farthest stray and the share inside the corridor. */ +export function compareRoutes(track: readonly TrackPoint[], suggestion: readonly TrackPoint[]): RouteComparison { + const project = projector([...track, ...suggestion]); + const line = suggestion.map(project); + const step = Math.max(1, Math.ceil(track.length / COMPARE_SAMPLES)); + const samples = track.filter((_, index) => index % step === 0 || index === track.length - 1).map(project); + let maxDeviationM = 0; + let within = 0; + for (const point of samples) { + let nearest = Infinity; + for (let i = 1; i < line.length && nearest > 0; i += 1) + nearest = Math.min(nearest, distanceToSegment(point, line[i - 1], line[i])); + if (line.length === 1) nearest = Math.hypot(point[0] - line[0][0], point[1] - line[0][1]); + maxDeviationM = Math.max(maxDeviationM, nearest); + if (nearest <= CORRIDOR_M) within += 1; + } + return { + originalKm: lineLengthMeters(track) / 1000, + suggestionKm: lineLengthMeters(suggestion) / 1000, + maxDeviationM, + withinCorridor: samples.length ? within / samples.length : 0, + }; +} diff --git a/client/src/components/Tours/conversion/useGpxConversion.ts b/client/src/components/Tours/conversion/useGpxConversion.ts new file mode 100644 index 000000000..cf9a49f64 --- /dev/null +++ b/client/src/components/Tours/conversion/useGpxConversion.ts @@ -0,0 +1,113 @@ +import type { TourMaxHikingDifficulty } from '@trek/shared'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { routeWalkingTour } from '../planner/tourRouting'; +import type { TourPlannerController } from '../planner/useTourPlanner'; +import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion'; + +/** The grade a planned version routes with when the import carries none: the paths a T3 walk may take. */ +const DEFAULT_DIFFICULTY: TourMaxHikingDifficulty = 3; + +type GpxConversionStatus = 'idle' | 'analyzing' | 'ready' | 'failed'; + +interface ConversionState { + /** The import the analysis belongs to; another import shown means it is idle again. */ + placeId: number | null; + status: GpxConversionStatus; + points: TrackPoint[]; + line: [number, number][] | null; + comparison: RouteComparison | null; + /** Why it failed: the track is too short to plan from, or the router found no way. */ + error: 'track' | 'routing' | null; +} + +const IDLE: ConversionState = { placeId: null, status: 'idle', points: [], line: null, comparison: null, error: null }; + +/** + * Planning an editable tour from an import shown in the tour planner (PR1-05A/B). + * The analysis starts only when asked: control points from the track, a route + * through them by the planner's own router, and how far that route strays from + * the track. Nothing is stored. Adopting hands the points to the planner as a + * new draft that remembers the import; only saving that draft creates a tour, + * and the import stays as it is. + */ +export function useGpxConversion(planner: TourPlannerController) { + const [state, setState] = useState(IDLE); + const abort = useRef(null); + const tour = planner.mode.type === 'view-gpx' ? (planner.readOnlyGpxTour?.tour ?? null) : null; + const track = planner.readOnlyGpxAnalysis?.routeCoordinates ?? null; + const current = tour !== null && state.placeId === tour.place_id ? state : IDLE; + const difficulty = (tour?.max_hiking_difficulty ?? DEFAULT_DIFFICULTY) as TourMaxHikingDifficulty; + + useEffect(() => () => abort.current?.abort(), []); + + const analyze = useCallback(async () => { + if (!tour || !track) return; + abort.current?.abort(); + const controller = new AbortController(); + abort.current = controller; + const trackPoints = track.map(([lat, lng]) => ({ lat, lng })); + const points = deriveControlPoints(trackPoints); + const base = { ...IDLE, placeId: tour.place_id, points }; + if (points.length < 2) { + setState({ ...base, status: 'failed', error: 'track' }); + return; + } + setState({ ...base, status: 'analyzing' }); + const routed = await routeWalkingTour(points, controller.signal, difficulty); + if (controller.signal.aborted) return; + if (!routed) { + setState({ ...base, status: 'failed', error: 'routing' }); + return; + } + const suggestion = routed.coordinates.map(([lat, lng]) => ({ lat, lng })); + setState({ + ...base, + status: 'ready', + line: routed.coordinates, + comparison: compareRoutes(trackPoints, suggestion), + }); + }, [difficulty, tour, track]); + + const cancel = useCallback(() => { + abort.current?.abort(); + setState(IDLE); + }, []); + + const { startDerivedTour } = planner; + /** + * The suggestion as a draft in the planner: the import planned over in place + * (`replace`), or a new tour beside it. True once the planner took it. + */ + const adopt = useCallback( + (how: 'replace' | 'new') => { + if (!tour || current.status !== 'ready') return false; + const started = startDerivedTour({ + name: tour.name, + description: tour.description ?? null, + website: tour.website ?? null, + difficulty, + points: current.points, + sourcePlaceId: tour.place_id, + inPlace: how === 'replace', + }); + if (started) setState(IDLE); + return started; + }, + [current.points, current.status, difficulty, startDerivedTour, tour] + ); + + return useMemo( + () => ({ + ...current, + /** The import shown can be planned from: it has a track and the user may add tours. */ + available: tour !== null && (track?.length ?? 0) >= 2 && planner.canEdit, + difficulty, + analyze, + cancel, + adopt, + }), + [adopt, analyze, cancel, current, difficulty, planner.canEdit, tour, track] + ); +} + +export type GpxConversion = ReturnType; diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx index 7d1464bb7..be58a6015 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -612,7 +612,11 @@ describe('TourPlannerRail', () => { expect(firstNumber).toHaveAttribute('data-waypoint-number', '1') expect(secondNumber).toHaveAttribute('data-waypoint-number', '2') expect(firstNumber).toHaveClass('bg-accent', 'text-accent-text', 'font-geist', 'tabular-nums', 'h-7', 'w-7') - expect(secondNumber).toHaveClass('bg-surface-card', 'text-content', 'font-geist', 'tabular-nums', 'h-7', 'w-7') + // Round like the map's pins and the plan's avatars. + expect(secondNumber).toHaveClass( + 'bg-edge', 'text-content', 'font-geist', 'tabular-nums', 'h-7', 'w-7', + 'rounded-full' + ) }) it('TOUR-PLANNER-RAIL-007: acknowledges alpine routing once per planner session', () => { @@ -761,17 +765,47 @@ describe('TourPlannerRail', () => { expect(screen.queryByRole('button', { name: 'Plan new tour' })).not.toBeInTheDocument() }) + it('PR1-05B: an import planned over in place offers its recorded track back, after a confirm', async () => { + const restore = vi.fn() + const controller = planner({ + mode: { type: 'edit-saved', placeId: tour.place_id }, + waypoints: [], + } as Partial) + const recordedTrack = { available: true, restoring: false, restore } + render() + + expect(screen.getByRole('region', { name: 'Recorded track' })).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Restore recorded track' })) + expect(restore).not.toHaveBeenCalled() + expect(screen.getByText('Restore the recorded track?')).toBeInTheDocument() + + const confirms = screen.getAllByRole('button', { name: 'Restore recorded track' }) + await userEvent.click(confirms[confirms.length - 1]) + expect(restore).toHaveBeenCalledOnce() + }) + + it('PR1-05B: a tour without a kept track shows no way back', () => { + const controller = planner({ + mode: { type: 'edit-saved', placeId: tour.place_id }, + waypoints: [], + } as Partial) + render( + + ) + expect(screen.queryByRole('region', { name: 'Recorded track' })).not.toBeInTheDocument() + }) + it('TOUR-PLANNER-RAIL-015: returns from GPX view to the blank editable Planner', () => { const controller = planner({ readOnlyGpxTour: { tour: { ...tour, has_waypoints: false }, routeGeometry: null }, readOnlyGpxAnalysis: null, }) const { rerender } = render() - expect(screen.getByText('GPX tours are view-only and cannot be edited in the planner yet.')).toBeInTheDocument() + expect(screen.getByText('Route geometry is unavailable for this GPX import.')).toBeInTheDocument() rerender() expect(screen.getByText('Plan a tour')).toBeInTheDocument() - expect(screen.queryByText('GPX tours are view-only and cannot be edited in the planner yet.')).not.toBeInTheDocument() + expect(screen.queryByText('Route geometry is unavailable for this GPX import.')).not.toBeInTheDocument() }) it('TOUR-PLANNER-RAIL-016: the master list reflects neutral, saved-edit, and GPX selection modes', () => { diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index b3256c5af..db622a428 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react' import type { TourListItem } from '@trek/shared' -import { AlertTriangle, ArrowLeft, Circle, Info, Loader2, Route, Plus, Redo2, RotateCcw, Save, Trash2, Undo2 } from 'lucide-react' +import { AlertTriangle, ArrowLeft, Circle, FileDown, History, Loader2, Mountain, Plus, Redo2, RotateCcw, Route, Save, Trash2, Undo2 } from 'lucide-react' import type { Day } from '../../../types' import { useTranslation } from '../../../i18n' import { useSettingsStore } from '../../../store/settingsStore' @@ -16,12 +16,18 @@ import { BarButton, SoftPill } from '../../Planner/planParts' import TourListRow from '../TourListRow' import ElevationProfile from '../../shared/ElevationProfile' import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation' -import { FoldButton, TourDayMenu, TourMetricFields, TourNotice, TourSection } from '../tourParts' +import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts' +import { tourMetricFacts } from '../tourFacts' import { TourDetailsSection, TourNameField } from './TourEditorFields' import TourWaypointList from './TourWaypointList' import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry' import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner' import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions' +import TourConversionSection from '../conversion/TourConversionSection' +import type { GpxConversion } from '../conversion/useGpxConversion' +import type { RecordedTrack } from '../useRecordedTrack' +import type { TourGpxImport } from '../useTourGpxImport' +import WandererMark from '../wanderer/WandererMark' const STATUS_KEY: Partial> = { dirty: 'tours.planner.status.dirty', @@ -33,7 +39,7 @@ const STATUS_KEY: Partial> = { } /** The tinted head band both rails open with, like every panel head in the planner. */ -const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5' +export const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5' type Translate = (key: string, params?: Record) => string @@ -45,7 +51,7 @@ function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric } /** The title of a rail's head band, with an optional line under it. */ -function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) { +export function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) { return (
    @@ -152,10 +158,147 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) { ) } -export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController } & TourPermissionProps) { +/** The rail while nothing is open: a new tour, or the wanderer trails. */ +function NeutralRail({ + planner, + canEdit, + onOpenWanderer, +}: { + planner: TourPlannerController + canEdit: boolean + onOpenWanderer?: () => void +}) { + const { t } = useTranslation() + return ( +
    +
    + + {canEdit && onOpenWanderer && ( + + + + )} +
    +
    + + {t('tours.planner.neutralBody')} +

    + } + /> +
    +
    + ) +} + +/** The rail for an imported tour: shown as it came in, measured, never edited. */ +function GpxViewRail({ + planner, + canEdit, + conversion, +}: { + planner: TourPlannerController + canEdit: boolean + conversion?: GpxConversion +}) { + const { t } = useTranslation() + const distanceUnit = useSettingsStore((state) => state.settings.distance_unit) + if (planner.mode.type !== 'view-gpx') return null + const { readOnlyGpxTour } = planner + const { tour } = readOnlyGpxTour + const fromWanderer = tourSource(tour) === 'wanderer' + const analysis = planner.readOnlyGpxAnalysis + const extra = + tour.duration != null + ? [ + { + label: t('tours.planner.inspector.duration'), + value: t('tours.durationMinutes', { count: Math.max(1, Math.round(tour.duration)) }), + }, + ] + : [] + return ( + <> +
    +
    + planner.returnToNeutral()} + className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" + > + + + {hikeSourceBadgeLabel(tour, t)}} + /> +
    +
    + {conversion && } + } title={t('tours.planner.inspector.title')}> + {analysis ? ( +
    + + +
    + ) : ( +

    + {t('tours.planner.gpxNoGeometry')} +

    + )} +
    +
    +
    + + + ) +} + +export function TourPlannerRail({ planner, + onOpenWanderer, + conversion, + recordedTrack, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController + /** Planning an editable tour from the import in view. */ + conversion?: GpxConversion + /** The way back to the recorded track of an import planned over in place. */ + recordedTrack?: RecordedTrack + /** Browse the user's wanderer trails instead; offered while nothing is being edited. */ + onOpenWanderer?: () => void +} & TourPermissionProps) { const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign }) const { t } = useTranslation() - const distanceUnit = useSettingsStore(state => state.settings.distance_unit) const [confirmDiscard, setConfirmDiscard] = useState(false) const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null) const alpineAcknowledged = useRef(false) @@ -170,69 +313,11 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a else planner.setMaxHikingDifficulty(value) } - if (planner.mode.type === 'neutral') { - return ( -
    -
    - -
    -
    - {t('tours.planner.neutralBody')}

    } + if (planner.mode.type === 'neutral') + return -
    -
    - ) - } - - if (planner.mode.type === 'view-gpx') { - const { tour } = planner.readOnlyGpxTour - const analysis = planner.readOnlyGpxAnalysis - const extra = tour.duration != null - ? [{ label: t('tours.planner.inspector.duration'), value: t('tours.durationMinutes', { count: Math.max(1, Math.round(tour.duration)) }) }] - : [] - return ( - <> -
    -
    - planner.returnToNeutral()} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"> - - - {hikeSourceBadgeLabel(tour, t)}} /> -
    -
    - }>{t('tours.detail.gpxReadOnly')} - {analysis ? ( - <> - - - - - - ) : ( - {t('tours.planner.gpxNoGeometry')} - )} -
    -
    - - - ) - } + if (planner.mode.type === 'view-gpx') + return const isSavedEdit = planner.mode.type === 'edit-saved' @@ -255,6 +340,8 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
    + {recordedTrack?.available && } + @@ -299,6 +386,48 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a ) } +/** + * In the editor of an import planned over in place: the recorded track is still + * kept, and one press (after a confirm) takes the tour back to it. + */ +function RecordedTrackCard({ recordedTrack, disabled }: { recordedTrack: RecordedTrack; disabled: boolean }) { + const { t } = useTranslation() + const [confirming, setConfirming] = useState(false) + return ( + } title={t('tours.recorded.title')}> +
    +

    + {t('tours.recorded.hint')} +

    + setConfirming(true)} + disabled={disabled || recordedTrack.restoring} + icon={ + recordedTrack.restoring ? ( + + ) : ( + + ) + } + > + {t('tours.recorded.restore')} + +
    + setConfirming(false)} + onConfirm={() => { + setConfirming(false) + void recordedTrack.restore() + }} + title={t('tours.recorded.confirmTitle')} + message={t('tours.recorded.confirmBody')} + confirmLabel={t('tours.recorded.restore')} + /> +
    + ) +} + /** The elevation profile under its label, folded away with the chevron beside it. */ function ElevationSection({ id, samples, gradientId, planner }: { id: string @@ -353,6 +482,29 @@ function NewTourConfirmDialog({ planner, canEdit }: { planner: TourPlannerContro ) } +/** The round GPX import button in the tours rail's head; the file drop on the workspace does the same. */ +function RailImportButton({ gpxImport, disabled }: { gpxImport: TourGpxImport; disabled: boolean }) { + const { t } = useTranslation() + return ( + + + + ) +} + interface TourPlannerToursRailProps extends TourPermissionProps { planner: TourPlannerController tours: TourListItem[] @@ -361,9 +513,12 @@ interface TourPlannerToursRailProps extends TourPermissionProps { onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise onViewGpxTour: (tour: TourListItem) => void onDeleteTour?: (placeId: number) => void + /** The workspace's GPX import; its button sits beside "New tour". */ + gpxImport?: TourGpxImport } -export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) { +export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, onDeleteTour, + gpxImport, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) { const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign }) const { t } = useTranslation() const toast = useToast() @@ -390,7 +545,8 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo } const selectTour = (tour: TourListItem) => { - if (tourSource(tour) === 'gpx') onViewGpxTour(tour) + // An import (a GPX file, a wanderer snapshot) has no waypoints of its own and is viewed as it came in. + if (tour.has_waypoints === false) onViewGpxTour(tour) else void openTour(tour) } @@ -426,6 +582,7 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
    -
    + )} {activeTab === 'transports' && ( diff --git a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx index 6486e8039..2a830b4f1 100644 --- a/client/src/pages/tripPlanner/TourPlannerMapStage.tsx +++ b/client/src/pages/tripPlanner/TourPlannerMapStage.tsx @@ -1,4 +1,5 @@ -import { lazy, Suspense, useEffect, useMemo, useState, type ReactElement } from 'react'; +import type { FerrataBbox } from '@trek/shared'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactElement } from 'react'; import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay'; import { crossingLegs, waypointsAtFerrataEnds } from '../../components/Ferratas/ferrataCrossings'; import { ferrataLabel, type FerrataGroup } from '../../components/Ferratas/ferrataGroups'; @@ -55,6 +56,8 @@ export interface TourPlannerMapStageProps canPlaceEdit: boolean; leftPanelPx: number; rightPanelPx: number; + /** Told the visible area too, besides the via ferrata layer (the wanderer area search). */ + onViewportChange?: (bounds: FerrataBbox) => void; } /** @@ -74,6 +77,7 @@ export function TourPlannerMapStage({ canPlaceEdit, leftPanelPx, rightPanelPx, + onViewportChange, }: Readonly): ReactElement { const [terrain3d, setTerrain3d] = useState(false); // How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock. @@ -87,6 +91,14 @@ export function TourPlannerMapStage({ ); const analysis = tourPlanner.mode.type === 'view-gpx' ? tourPlanner.readOnlyGpxAnalysis : tourPlanner.routeAnalysis; const ferrata = useFerrataMapLayer(tripId, ferrataArea, analysis?.routeCoordinates ?? null); + const ferrataViewportChange = ferrata.onViewportChange; + const viewportChanged = useCallback( + (bounds: FerrataBbox) => { + ferrataViewportChange(bounds); + onViewportChange?.(bounds); + }, + [ferrataViewportChange, onViewportChange] + ); // The rails beside the map warn about the stretches that climb a ferrata, and band them on the profile. const { t: translate, language } = useTranslation(); const tourWaypoints = tourPlanner.mode.type === 'view-gpx' ? NO_POINTS : tourPlanner.waypoints; @@ -120,6 +132,7 @@ export function TourPlannerMapStage({ mode: terrain3d ? '3d' : '2d', ferrataOpen: !!ferrata.selected, guard: closingPress.guard, + previewPoints: tourMap.previewPoints, }); const onAdd = waypoints.onAdd; // What each panel covers of the map, a collapsed one its rail. @@ -152,7 +165,7 @@ export function TourPlannerMapStage({ waypoints={waypoints} framePoints={tourMap.focusPoints} ferrataLayer={ferrata.layer} - onViewportChange={ferrata.onViewportChange} + onViewportChange={viewportChanged} onControlsHeightChange={setTerrainControlsPx} onLegendLayout={setTerrainLegend} placeTool={placeTool} @@ -163,6 +176,7 @@ export function TourPlannerMapStage({ tripId={tripId} places={[]} route={tourMap.route} + routeWalking={tourMap.routeWalking} followSelection={false} onMapClick={ onAdd @@ -183,7 +197,7 @@ export function TourPlannerMapStage({ onViewBaseLayerChange={tourPlanner.setMapBaseLayer} onToggleTerrain3d={() => setTerrain3d(true)} ferrataLayer={ferrata.layer} - onViewportChange={ferrata.onViewportChange} + onViewportChange={viewportChanged} placeTool={placeTool} /> )} diff --git a/client/src/pages/tripPlanner/b4a-wiring.test.ts b/client/src/pages/tripPlanner/b4a-wiring.test.ts index 38325b414..298f1163d 100644 --- a/client/src/pages/tripPlanner/b4a-wiring.test.ts +++ b/client/src/pages/tripPlanner/b4a-wiring.test.ts @@ -14,6 +14,8 @@ const pageControllerPath = 'client/src/pages/tripPlanner/useTripPlannerPage.ts' const pageText = readFileSync(resolve(process.cwd(), 'src/pages/TripPlannerPage.tsx'), 'utf8') const hookText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlanner.ts'), 'utf8') const pageControllerText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTripPlannerPage.ts'), 'utf8') +// The tours workspace (editor, GPX import, wanderer) is wired in its own hook the page hook calls. +const workspaceText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/useTourWorkspace.ts'), 'utf8') // The Tours map and its edit gates live in the map stage the page renders. const mapStageText = readFileSync(resolve(process.cwd(), 'src/pages/tripPlanner/TourPlannerMapStage.tsx'), 'utf8') // Which clicks place a point is decided by the map stage's points hook. @@ -22,6 +24,7 @@ const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts const page = parse(pagePath, pageText) const hook = parse(hookPath, hookText) const pageController = parse(pageControllerPath, pageControllerText) +const workspace = parse('client/src/pages/tripPlanner/useTourWorkspace.ts', workspaceText) const mapStage = parse('client/src/pages/tripPlanner/TourPlannerMapStage.tsx', mapStageText) function collect(source: ts.Node, predicate: (node: ts.Node) => boolean): ts.Node[] { @@ -127,7 +130,8 @@ test('B4A desktop page delegates Tours state and permissions to its page hook', && ts.isIdentifier(node.expression) && node.expression.text === 'useTripPlannerPage')[0] assert.ok(pageHookCall) - assert.ok(callNamed(pageController, 'useTourPlanner')) + assert.ok(callNamed(pageController, 'useTourWorkspace')) + assert.ok(callNamed(workspace, 'useTourPlanner')) const mobileFiles = mobileSourceFiles(resolve(process.cwd(), 'src/mobile')) for (const file of mobileFiles) { const mobileSource = parse(file, readFileSync(file, 'utf8')) @@ -153,11 +157,17 @@ test('B4A desktop page delegates Tours state and permissions to its page hook', assert.ok(ts.isCallExpression(permissions) && ts.isIdentifier(permissions.expression) && permissions.expression.text === 'can') - const tourPlannerCall = callNamed(pageController, 'useTourPlanner') + // The page hook hands both rights to the tours workspace, which hands them to the planner. + const workspaceCall = callNamed(pageController, 'useTourWorkspace') + assert.deepEqual( + workspaceCall?.arguments.map((argument) => argument.getText(pageController)), + ['planner', 'canPlaceEdit', 'canDayEdit'] + ) + const tourPlannerCall = callNamed(workspace, 'useTourPlanner') assert.ok(tourPlannerCall && ts.isObjectLiteralExpression(tourPlannerCall.arguments[0])) const tourPlannerOptions = tourPlannerCall.arguments[0] - assert.equal(propertyValue(tourPlannerOptions, 'canEdit')?.getText(pageController), 'canPlaceEdit') - assert.equal(propertyValue(tourPlannerOptions, 'canAssign')?.getText(pageController), 'canDayEdit') + assert.equal(propertyValue(tourPlannerOptions, 'canEdit')?.getText(workspace), 'canPlaceEdit') + assert.equal(propertyValue(tourPlannerOptions, 'canAssign')?.getText(workspace), 'canDayEdit') const returnedViewModel = collect(pageController, node => ts.isReturnStatement(node) && !!node.expression && ts.isObjectLiteralExpression(node.expression)) @@ -170,7 +180,7 @@ test('B4A desktop page delegates Tours state and permissions to its page hook', assert.ok(returnedPermissions.properties.some(property => ts.isShorthandPropertyAssignment(property) && property.name.text === 'canDayEdit')) - for (const name of ['TourPlannerRail', 'TourPlannerToursRail', 'ToursSidebar', 'TourDetailDialog']) { + for (const name of ['TourWorkspaceRail', 'TourPlannerToursRail', 'ToursSidebar', 'TourDetailDialog']) { const tags = jsxTags(page, name) assert.equal(tags.length, name === 'TourDetailDialog' ? 2 : 1) for (const tag of tags) { @@ -197,14 +207,16 @@ test('B4A desktop page delegates Tours state and permissions to its page hook', }) test('B4A dedicated desktop controller retains GPX, draft, focus, and saved rail wiring', () => { - assert.ok(callNamed(pageController, 'useTourPlanner')) + assert.ok(callNamed(pageController, 'useTourWorkspace')) + assert.ok(callNamed(workspace, 'useTourPlanner')) assert.ok(pageText.includes('const pageState = useTripPlannerPage()')) assert.ok(pageControllerText.includes("tourPlanner.mode.type === 'view-gpx'")) for (const mode of ['new-draft', 'edit-saved']) assert.ok(mapPointsText.includes(`tourPlanner.mode.type === '${mode}'`)) assert.match(pageControllerText, /readOnlyGpxAnalysis\?\.routeCoordinates/) assert.match(mapStageText, /routeProfileFocus=\{tourPlanner\.routeProfileFocus\}/) - assert.match(pageText, /tourPlanner\.viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/) - assert.match(pageControllerText, /onSaved:[\s\S]*?upsertTour\(result\.tour\)/) + assert.match(workspaceText, /viewGpxTour\(tour, place\?\.route_geometry \?\? null\)/) + assert.match(pageText, /onViewGpxTour=\{viewImportedTour\}/) + assert.match(workspaceText, /onSaved:[\s\S]*?upsertTour\(result\.tour\)/) assert.doesNotMatch(pageText, /setSelectedPlaceId\(result\.tour/) }) diff --git a/client/src/pages/tripPlanner/plannerLazy.test.ts b/client/src/pages/tripPlanner/plannerLazy.test.ts index dcf60c73d..2eacb9cb4 100644 --- a/client/src/pages/tripPlanner/plannerLazy.test.ts +++ b/client/src/pages/tripPlanner/plannerLazy.test.ts @@ -1,7 +1,3 @@ -// FE-PAGE-PLANNER-LAZY-001 to FE-PAGE-PLANNER-LAZY-002 -// -// Every planner panel loads as a chunk of its own. The targets are stubbed so -// each loader, and each hop to a named export, is checked by what it renders. import { createElement, Suspense, type ComponentType } from 'react' import { render, screen } from '@testing-library/react' import * as lazy from './plannerLazy' @@ -22,6 +18,7 @@ vi.mock('../../components/Roadtrip/RoadtripStopPopup', () => ({ default: label(' vi.mock('../../components/Roadtrip/RoadtripStayModal', () => ({ default: label('roadtrip stay modal') })) vi.mock('../../components/Roadtrip/RoadtripTrackModal', () => ({ default: label('roadtrip track modal') })) vi.mock('../../components/Roadtrip/RoadtripAlternativesBar', () => ({ default: label('roadtrip alternatives bar') })) +vi.mock('../../components/Tours/TourWorkspaceRail', () => ({ default: label('tour workspace rail') })) vi.mock('../../components/Tours/planner/TourPlannerPanels', () => ({ TourPlannerRail: label('tour planner rail'), TourPlannerToursRail: label('tour planner tours rail'), @@ -43,7 +40,7 @@ const cases: Array<[keyof typeof lazy, string]> = [ ['RoadtripStayModal', 'roadtrip stay modal'], ['RoadtripTrackModal', 'roadtrip track modal'], ['RoadtripAlternativesBar', 'roadtrip alternatives bar'], - ['TourPlannerRail', 'tour planner rail'], + ['TourWorkspaceRail', 'tour workspace rail'], ['TourPlannerToursRail', 'tour planner tours rail'], ['TransportModal', 'transport modal'], ] diff --git a/client/src/pages/tripPlanner/plannerLazy.ts b/client/src/pages/tripPlanner/plannerLazy.ts index e94c9efac..2ac6fcb61 100644 --- a/client/src/pages/tripPlanner/plannerLazy.ts +++ b/client/src/pages/tripPlanner/plannerLazy.ts @@ -24,8 +24,8 @@ export const RoadtripStopPopup = lazyWithRetry(() => import('../../components/Ro export const RoadtripStayModal = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripStayModal')) export const RoadtripTrackModal = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripTrackModal')) export const RoadtripAlternativesBar = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripAlternativesBar')) -export const TourPlannerRail = lazyWithRetry(() => - import('../../components/Tours/planner/TourPlannerPanels').then(module => ({ default: module.TourPlannerRail })) +export const TourWorkspaceRail = lazyWithRetry(() => + import('../../components/Tours/TourWorkspaceRail') ) export const TourPlannerToursRail = lazyWithRetry(() => import('../../components/Tours/planner/TourPlannerPanels').then(module => ({ default: module.TourPlannerToursRail })) diff --git a/client/src/pages/tripPlanner/useDayPlanEdits.ts b/client/src/pages/tripPlanner/useDayPlanEdits.ts index 24bc2e473..623e3787c 100644 --- a/client/src/pages/tripPlanner/useDayPlanEdits.ts +++ b/client/src/pages/tripPlanner/useDayPlanEdits.ts @@ -26,6 +26,9 @@ interface DayPlanEditsOptions updateRouteForDay: ReturnType['updateRouteForDay'] } +/** The day's number in the trip, as the plan shows it. */ +const dayNumberOf = (dayId: number) => useTripStore.getState().days.findIndex(day => day.id === dayId) + 1 + /** * Edits to the day plan, each with its undo where one makes sense: putting a place on * a day, moving a stop to another day, taking one off, reordering a day, renaming a @@ -42,7 +45,8 @@ export function useDayPlanEdits(options: DayPlanEditsOptions) { const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => { const target = dayId || selectedDayId if (!target) { toast.error(t('trip.toast.selectDay')); return false } - if (isTourPlace(placeId) && (storedAssignments[String(target)] ?? []).some(assignment => assignment.place_id === placeId)) return false + // A tour is on a day at most once; a drop onto such a day still gets here, and says why nothing happens. + if (isTourPlace(placeId) && (storedAssignments[String(target)] ?? []).some(a => a.place_id === placeId)) { toast.info(t('tours.alreadyOnDay')); return false } const place = places.find(p => p.id === placeId) // A place with a start of its own is drawn by it, so it is stored there too, the // way a stop moved over from another day is. Without one it goes where it was put. @@ -56,7 +60,7 @@ export function useDayPlanEdits(options: DayPlanEditsOptions) { return false } if (isTourPlace(placeId)) await reloadTourPlaceIds() - toast.success(t('trip.toast.assignedToDay')) + toast.success(isTourPlace(placeId) ? t('tours.addedToDay', { n: dayNumberOf(target) }) : t('trip.toast.assignedToDay')) if (assignment?.id) { const capturedAssignmentId = assignment.id const capturedTarget = target diff --git a/client/src/pages/tripPlanner/useTourMapPoints.ts b/client/src/pages/tripPlanner/useTourMapPoints.ts index e3bb7db3f..b3fd34372 100644 --- a/client/src/pages/tripPlanner/useTourMapPoints.ts +++ b/client/src/pages/tripPlanner/useTourMapPoints.ts @@ -34,6 +34,8 @@ interface Options { ferrataOpen: boolean; /** Drops the click that closed a ferrata card. */ guard: ReturnType['guard']; + /** Control points of an editable version previewed over an import: shown, never moved. */ + previewPoints?: readonly { lat: number; lng: number }[] | null; } /** @@ -42,7 +44,7 @@ interface Options { * after a point went in. A click places a point only while the tool is on, * only when it was a click and not a drag, and never when it closed a card. */ -export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, guard }: Options) { +export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, guard, previewPoints }: Options) { const { t } = useTranslation(); const editing = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved'; // The same lookup as the waypoint list, so a pin is named on hover as its row is. @@ -65,17 +67,28 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, ferrataOpen, const onAdd = guard(gate(placeOn ? addPoint : undefined)); // Kept by identity while nothing about the points changed, so the map does not redraw its pins on every render. const { waypoints } = tourPlanner; - const points = useMemo( - () => - editing - ? waypoints.map((point, index) => ({ - ...point, - name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }), - detail: t(ROLE_KEY[point.role]), - })) - : NO_POINTS, - [editing, waypoints, placeNames, t] - ); + const points = useMemo(() => { + if (editing) { + return waypoints.map((point, index) => ({ + ...point, + name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }), + detail: t(ROLE_KEY[point.role]), + })); + } + if (!previewPoints?.length) return NO_POINTS; + const last = previewPoints.length - 1; + return previewPoints.map((point, index) => { + const role = index === 0 ? 'start' : index === last ? 'end' : 'via'; + return { + id: `preview-${index}`, + lat: point.lat, + lng: point.lng, + role, + name: t('tours.planner.waypointLabel', { n: index + 1 }), + detail: t(ROLE_KEY[role]), + } as const; + }); + }, [editing, waypoints, placeNames, previewPoints, t]); return { waypoints: { diff --git a/client/src/pages/tripPlanner/useTourWorkspace.ts b/client/src/pages/tripPlanner/useTourWorkspace.ts new file mode 100644 index 000000000..c146b3dc0 --- /dev/null +++ b/client/src/pages/tripPlanner/useTourWorkspace.ts @@ -0,0 +1,120 @@ +import type { TourListItem } from '@trek/shared'; +import { useCallback } from 'react'; +import { useGpxConversion } from '../../components/Tours/conversion/useGpxConversion'; +import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner'; +import { useRecordedTrack } from '../../components/Tours/useRecordedTrack'; +import { useTourGpxImport } from '../../components/Tours/useTourGpxImport'; +import { useWandererBrowser } from '../../components/Tours/wanderer/useWandererBrowser'; +import { useAddonStore } from '../../store/addonStore'; +import { useTripStore } from '../../store/tripStore'; +import type { useTripPlanner } from './useTripPlanner'; + +/** + * The desktop tours workspace: the tour editor, its GPX import (button and file drop) + * and the wanderer trail browser, wired so that whatever comes in opens on the map. + */ +export function useTourWorkspace( + planner: ReturnType, + canPlaceEdit: boolean, + canDayEdit: boolean +) { + const { + activeTab, + enabledAddons, + invalidateTourPlaceIds, + isMobile, + places, + reloadTourPlaceIds, + t, + toast, + tours, + tripActions, + tripId, + upsertTour, + } = planner; + // wanderer only while the admin allows it under Tours (the store reads Tours being on into it too). + const wandererAllowed = useAddonStore((state) => state.toursWanderer); + + const tourPlanner = useTourPlanner({ + tripId, + canEdit: canPlaceEdit, + canAssign: canDayEdit, + active: activeTab === 'tour-planner' && enabledAddons.tours && !isMobile, + onSaved: async (result) => { + upsertTour(result.tour); + toast.success(t('tours.planner.saved')); + await tripActions.loadTrip(tripId); + void reloadTourPlaceIds(); + }, + }); + + // A tour of the trip opened in the planner: an import has no waypoints of its own and + // shows as it came in, a planned tour opens in the editor. Never over an edit it would + // replace. + const { hasUnsavedChanges, viewGpxTour, openTour } = tourPlanner; + /** An import shown as it came in, over whatever the planner shows; the caller asked before discarding. */ + const viewImportedTour = useCallback( + (tour: TourListItem) => { + // The store first: right after an import the page's list may not have the new place yet. + const byId = (item: { id: number }) => item.id === tour.place_id; + const place = useTripStore.getState().places.find(byId) ?? places.find(byId); + viewGpxTour(tour, place?.route_geometry ?? null); + }, + [places, viewGpxTour] + ); + const openTourInPlanner = useCallback( + (placeId: number, known?: TourListItem) => { + const tour = known ?? tours.find((item) => item.place_id === placeId); + if (!tour || hasUnsavedChanges) return; + if (tour.has_waypoints === false) viewImportedTour(tour); + else void openTour(tour); + }, + [hasUnsavedChanges, openTour, tours, viewImportedTour] + ); + + // The tours workspace's GPX import, shared by its button and its file drop. One new + // tour opens on the map at once. + const openImportedTours = useCallback( + (imported: TourListItem[]) => { + if (imported.length === 1) openTourInPlanner(imported[0].place_id, imported[0]); + }, + [openTourInPlanner] + ); + const tourGpxImport = useTourGpxImport({ + tripId, + canEdit: canPlaceEdit, + onToursChanged: (placeIds) => invalidateTourPlaceIds(placeIds ? { placeIds } : undefined), + onImported: openImportedTours, + }); + + // Browsing the user's wanderer trails in place of the editor rail; the trail shows on the map. + const wandererBrowser = useWandererBrowser({ + tripId, + enabled: activeTab === 'tour-planner' && wandererAllowed && !isMobile && canPlaceEdit, + onImported: async (tour) => { + upsertTour(tour); + await useTripStore.getState().refreshPlaces(tripId); + void invalidateTourPlaceIds({ placeIds: [tour.place_id] }); + openTourInPlanner(tour.place_id, tour); + }, + onOpenTour: openTourInPlanner, + }); + + // An import planned as an editable tour: analysed on request, adopted over itself or as a new draft. + const tourConversion = useGpxConversion(tourPlanner); + // The way back from an import planned over in place to its recorded track. + const recordedTrack = useRecordedTrack({ + tripId, + planner: tourPlanner, + tours, + canEdit: canPlaceEdit, + onRestored: async (tour) => { + upsertTour(tour); + await useTripStore.getState().refreshPlaces(tripId); + void invalidateTourPlaceIds({ placeIds: [tour.place_id] }); + viewImportedTour(tour); + }, + }); + + return { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack }; +} diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts index b778bbb47..42b827fc2 100644 --- a/client/src/pages/tripPlanner/useTripPlannerPage.ts +++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts @@ -1,46 +1,29 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { TourListItem } from '@trek/shared' import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore' import { placeActions } from '../../components/Planner/usePlaceActions' -import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner' import { tourPlannedTimes as projectTourPlannedTimes } from '../../components/Tours/tourPresentation' import { usePlanMapViewport } from '../../hooks/usePlanMapViewport' import { useMapLocationSearch } from './useMapLocationSearch' import { useTripPlanner } from './useTripPlanner' +import { useTourWorkspace } from './useTourWorkspace' export function useTripPlannerPage() { const planner = useTripPlanner() const { activeTab, - can, - enabledAddons, - isMobile, - places, - reloadTourPlaceIds, + can, places, setSelectedPlaceId, t, toast, tours, trip, tripActions, - tripId, - upsertTour, - } = planner + tripId } = planner const canPlaceEdit = can('place_edit', trip) const canDayEdit = can('day_edit', trip) - const tourPlanner = useTourPlanner({ - tripId, - canEdit: canPlaceEdit, - canAssign: canDayEdit, - active: activeTab === 'tour-planner' && enabledAddons.tours && !isMobile, - onSaved: async result => { - upsertTour(result.tour) - toast.success(t('tours.planner.saved')) - await tripActions.loadTrip(tripId) - void reloadTourPlaceIds() - }, - }) + const { tourPlanner, viewImportedTour, tourGpxImport, wandererBrowser, tourConversion, recordedTrack } = + useTourWorkspace(planner, canPlaceEdit, canDayEdit) const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null) const lastPlanMapViewportRef = useRef(null) @@ -64,6 +47,7 @@ export function useTripPlannerPage() { }, [places, plannerViewportSnapshot]) const focusPoints = useMemo<[number, number][]>(() => { + if (wandererBrowser.open) return wandererBrowser.line ?? plannerFallbackFocusPoints if (tourPlanner.mode.type === 'view-gpx') return tourPlanner.readOnlyGpxAnalysis?.routeCoordinates ?? [] if (tourPlanner.mode.type === 'new-draft') { return tourPlanner.waypoints.map(point => [point.lat, point.lng]) @@ -72,20 +56,31 @@ export function useTripPlannerPage() { return tourPlanner.waypoints.map(point => [point.lat, point.lng]) } return plannerFallbackFocusPoints - }, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints]) + }, [ + wandererBrowser.open, + wandererBrowser.line, + tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints]) const { mapFocusPoints, mapFocusKey, locationSearch } = useMapLocationSearch(tourPlanner.mapFocusKey, focusPoints) const route = useMemo<[number, number][][] | null>(() => { + if (wandererBrowser.open) return wandererBrowser.line ? [wandererBrowser.line] : null if (tourPlanner.mode.type === 'view-gpx') { const coordinates = tourPlanner.readOnlyGpxAnalysis?.routeCoordinates - return coordinates?.length ? [coordinates] : null + if (!coordinates?.length) return null + // While an editable version is previewed, the import (dashed) and the suggestion side by side. + return tourConversion.line ? [coordinates, tourConversion.line] : [coordinates] } if (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved') { return tourPlanner.route ? [tourPlanner.route] : null } return null - }, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.route]) + }, [ + wandererBrowser.open, + wandererBrowser.line, + tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.route, + tourConversion.line, + ]) const { closeGpxTour } = tourPlanner useEffect(() => { @@ -106,7 +101,7 @@ export function useTripPlannerPage() { // Each tour's planned times keyed by its place, for the day plan's tour rows. const plannedTourDurations = new Map(tours.map(tour => [tour.place_id, { ...projectTourPlannedTimes(tour), tour }] as const)) - const onSelectTour = (tour: TourListItem | null, opener?: HTMLElement) => { + const onSelectTour = (tour: (typeof tours)[number] | null, opener?: HTMLElement) => { tourDetailOpenerRef.current = tour && opener ? { placeId: tour.place_id, element: opener } : null setSelectedPlaceId(tour?.place_id ?? null) } @@ -118,8 +113,20 @@ export function useTripPlannerPage() { placeActions: placeActions({ tripId, tripActions, toast, t }), permissions: { canPlaceEdit, canDayEdit }, tourPlanner, + tourGpxImport, + wandererBrowser, + viewImportedTour, tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour }, - tourMap: { route, focusPoints: mapFocusPoints, focusKey: mapFocusKey, captureViewport }, + tourConversion, + recordedTrack, + tourMap: { route, + routeWalking: route && route.length === 2 && tourConversion.line ? [true, false] : null, + previewPoints: tourConversion.status === 'idle' ? null : tourConversion.points, + focusPoints: mapFocusPoints, + // A picked wanderer trail frames itself; the rest follows the planner. + focusKey: + wandererBrowser.open && wandererBrowser.selected ? `wanderer:${wandererBrowser.selected.id}` : mapFocusKey, captureViewport, + }, locationSearch, plannedTourDurations, } diff --git a/client/src/repo/reverseGeocodeRepo.test.ts b/client/src/repo/reverseGeocodeRepo.test.ts index ba44b5aae..c16efa0b1 100644 --- a/client/src/repo/reverseGeocodeRepo.test.ts +++ b/client/src/repo/reverseGeocodeRepo.test.ts @@ -66,4 +66,51 @@ describe('reverse geocoded place names', () => { resolvers[2]({ name: 'Three', address: null }); await expect(Promise.all(names)).resolves.toEqual(['One', 'Two', 'Three']); }); + it('FE-REVERSE-GEOCODE-007: an answered name outlives a reload and is not asked for again', async () => { + await reverseGeocodeRepo.placeName(47.42, 11.01, 'de'); + vi.resetModules(); + const reloaded = (await import('./reverseGeocodeRepo')).reverseGeocodeRepo; + const network = (await import('../api/client')).mapsApi; + vi.mocked(network.reverse).mockClear(); + + await expect(reloaded.placeName(47.42, 11.01, 'de')).resolves.toBe('Zugspitze'); + expect(reloaded.knownName(47.42, 11.01, 'de')).toBe('Zugspitze'); + expect(network.reverse).not.toHaveBeenCalled(); + }); + + it('FE-REVERSE-GEOCODE-008: a name answered before is there offline too', async () => { + await reverseGeocodeRepo.placeName(47.43, 11.02, 'de'); + vi.mocked(isEffectivelyOffline).mockReturnValue(true); + await expect(reverseGeocodeRepo.placeName(47.43, 11.02, 'de')).resolves.toBe('Zugspitze'); + }); + + it('FE-REVERSE-GEOCODE-009: knows a name only once it was answered, and per language', async () => { + expect(reverseGeocodeRepo.knownName(47.44, 11.03, 'de')).toBeUndefined(); + await reverseGeocodeRepo.placeName(47.44, 11.03, 'de'); + expect(reverseGeocodeRepo.knownName(47.44, 11.03, 'de')).toBe('Zugspitze'); + expect(reverseGeocodeRepo.knownName(47.44, 11.03, 'en')).toBeUndefined(); + }); + + it('FE-REVERSE-GEOCODE-010: keeps no answer without a name, so it is asked again later', async () => { + vi.mocked(mapsApi.reverse).mockResolvedValueOnce({ name: null, address: null }); + await expect(reverseGeocodeRepo.placeName(47.45, 11.04, 'de')).resolves.toBeNull(); + expect(reverseGeocodeRepo.knownName(47.45, 11.04, 'de')).toBeUndefined(); + }); + + it('FE-REVERSE-GEOCODE-011: drops the oldest kept name past the limit and survives broken storage', async () => { + const seeded = Array.from({ length: 2000 }, (_, n) => [`${n},0|de`, `Place ${n}`]); + localStorage.setItem('trek-place-names-v1', JSON.stringify(seeded)); + vi.resetModules(); + const reloaded = (await import('./reverseGeocodeRepo')).reverseGeocodeRepo; + await reloaded.placeName(47.46, 11.05, 'de'); + const kept = JSON.parse(localStorage.getItem('trek-place-names-v1') ?? '[]') as [string, string][]; + expect(kept).toHaveLength(2000); + expect(kept[0][0]).toBe('1,0|de'); + expect(kept[kept.length - 1]).toEqual(['47.46000,11.05000|de', 'Zugspitze']); + + localStorage.setItem('trek-place-names-v1', '{broken'); + vi.resetModules(); + const fresh = (await import('./reverseGeocodeRepo')).reverseGeocodeRepo; + expect(fresh.knownName(47.46, 11.05, 'de')).toBeUndefined(); + }); }); diff --git a/client/src/repo/reverseGeocodeRepo.ts b/client/src/repo/reverseGeocodeRepo.ts index f5f18cf50..afc102fb8 100644 --- a/client/src/repo/reverseGeocodeRepo.ts +++ b/client/src/repo/reverseGeocodeRepo.ts @@ -3,6 +3,10 @@ import { isEffectivelyOffline } from '../sync/networkMode'; /** How many coordinates the cache remembers before it starts over. */ const CACHE_LIMIT = 500; +/** How many answered names outlive a reload; the oldest give way first. */ +const STORED_LIMIT = 2000; +/** Where the answered names are kept between visits: a convenience, rebuilt from the geocoder when gone. */ +const STORAGE_KEY = 'trek-place-names-v1'; /** How many lookups may be in flight at once, so a long tour does not flood the geocoder. */ const CONCURRENCY = 2; @@ -34,6 +38,39 @@ function runQueued(task: () => Promise): Promise { }); } +/** The names answered on earlier visits, read once; an unreadable store is an empty one. */ +let stored: Map | null = null; + +function storedNames(): Map { + if (stored) return stored; + stored = new Map(); + try { + const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as unknown; + if (Array.isArray(raw)) { + for (const entry of raw) { + if (Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'string') + stored.set(entry[0], entry[1]); + } + } + } catch { + // Blocked or broken storage: names are simply asked for again. + } + return stored; +} + +/** Keeps an answered name for later visits, dropping the oldest past the limit. */ +function storeName(key: string, name: string): void { + const names = storedNames(); + names.delete(key); + names.set(key, name); + while (names.size > STORED_LIMIT) names.delete(names.keys().next().value as string); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify([...names])); + } catch { + // Full or blocked storage: the name still serves this visit from memory. + } +} + /** The name a reverse lookup gives, or the first part of its address when it has no name. */ function placeLabel(result: { name: string | null; address: string | null }): string | null { const name = result.name?.trim(); @@ -49,20 +86,39 @@ export const reverseGeocodeRepo = { * same point is asked again the next time it is shown. */ placeName(lat: number, lng: number, lang: string): Promise { - if (isEffectivelyOffline()) return Promise.reject(new Error('Offline')); const key = keyOf(lat, lng, lang); + const known = storedNames().get(key); + if (known !== undefined) return Promise.resolve(known); + if (isEffectivelyOffline()) return Promise.reject(new Error('Offline')); const cached = cache.get(key); if (cached !== undefined) return cached; if (cache.size >= CACHE_LIMIT) cache.clear(); - const value = runQueued(() => mapsApi.reverse(lat, lng, lang)).then(placeLabel); + const value = runQueued(() => mapsApi.reverse(lat, lng, lang)).then((result) => { + const label = placeLabel(result); + if (label) storeName(key, label); + return label; + }); cache.set(key, value); void value.catch(() => { if (cache.get(key) === value) cache.delete(key); }); return value; }, - /** Forgets every remembered name; for tests. */ + /** + * A name answered before, on this visit or an earlier one, without asking; + * undefined when the coordinate has not been answered with a name yet. + */ + knownName(lat: number, lng: number, lang: string): string | undefined { + return storedNames().get(keyOf(lat, lng, lang)); + }, + /** Forgets every remembered name, kept ones included; for tests. */ clear() { cache.clear(); + stored = new Map(); + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Nothing kept to forget. + } }, }; diff --git a/client/src/repo/tourRepo.ts b/client/src/repo/tourRepo.ts index 62ed31eb6..692feb858 100644 --- a/client/src/repo/tourRepo.ts +++ b/client/src/repo/tourRepo.ts @@ -176,6 +176,13 @@ export const tourRepo = { return result }, + /** Online only, like the import: the recorded track is kept on the server, not in the cache. */ + async restoreRecorded(tripId: number | string, placeId: number): Promise { + const result = await toursApi.restoreRecorded(tripId, placeId) + await offlineDb.tours.put({ ...result.tour, trip_id: Number(tripId), waypoints: result.waypoints }) + return result + }, + async importGpx(tripId: number | string, file: File): Promise { const result = await toursApi.importGpx(tripId, file) await offlineDb.tours.bulkPut(result.tours.map(tour => ({ ...tour, trip_id: Number(tripId) }))) diff --git a/client/src/repo/wandererRepo.test.ts b/client/src/repo/wandererRepo.test.ts new file mode 100644 index 000000000..90f5fb11c --- /dev/null +++ b/client/src/repo/wandererRepo.test.ts @@ -0,0 +1,74 @@ +import { http, HttpResponse } from 'msw'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { server } from '../../tests/helpers/msw/server'; +import { offlineDb } from '../db/offlineDb'; +import { isEffectivelyOffline } from '../sync/networkMode'; +import { wandererErrorCode, WandererOfflineError, wandererRepo } from './wandererRepo'; + +vi.mock('../sync/networkMode', async (importOriginal) => ({ + ...(await importOriginal()), + isEffectivelyOffline: vi.fn(() => false), +})); + +const tour = { + place_id: 70, + name: 'Ridge', + tour_type: 'hike', + distance: 5, + elevation_gain: 300, + elevation_loss: 300, + duration: null, + difficulty: 'easy', + wanderer_ref: 'https://w.test#abc', + match_confidence: 1, + max_hiking_difficulty: 1, + planned: false, + caution: false, + has_waypoints: false, +}; + +beforeEach(async () => { + vi.mocked(isEffectivelyOffline).mockReturnValue(false); + await offlineDb.tours.clear(); +}); + +describe('wandererRepo', () => { + it('RS-04: searches and previews through the server, with the trail id kept safe in the path', async () => { + const seen: string[] = []; + server.use( + http.post('/api/trips/3/tours/wanderer/search', async ({ request }) => { + seen.push(JSON.stringify(await request.json())); + return HttpResponse.json({ instance: 'https://w.test', trails: [], total: 0, pageSize: 20 }); + }), + http.get('/api/trips/3/tours/wanderer/trails/:id', ({ params }) => { + seen.push(String(params.id)); + return HttpResponse.json({ ok: true }); + }) + ); + await expect(wandererRepo.search(3, { q: 'ridge', page: 1 })).resolves.toMatchObject({ total: 0 }); + await wandererRepo.preview(3, 'abc'); + expect(seen).toEqual(['{"q":"ridge","page":1}', 'abc']); + }); + + it('RS-05: an import is cached like every other tour', async () => { + server.use(http.post('/api/trips/3/tours/wanderer/import', () => HttpResponse.json({ tour }))); + await wandererRepo.importTrail(3, { trailId: 'abc', contentHash: '0123456789abcdef' }); + expect(await offlineDb.tours.get(70)).toMatchObject({ place_id: 70, trip_id: 3 }); + }); + + it('RS-04: is online only and says so instead of sending', async () => { + vi.mocked(isEffectivelyOffline).mockReturnValue(true); + await expect(wandererRepo.search(3, { q: '', page: 1 })).rejects.toBeInstanceOf(WandererOfflineError); + await expect(wandererRepo.preview(3, 'abc')).rejects.toBeInstanceOf(WandererOfflineError); + await expect( + wandererRepo.importTrail(3, { trailId: 'abc', contentHash: '0123456789abcdef' }) + ).rejects.toBeInstanceOf(WandererOfflineError); + }); + + it('RS-03: reads the reason of a failed call from the server, the offline case or nothing', () => { + expect(wandererErrorCode(new WandererOfflineError())).toBe('offline'); + expect(wandererErrorCode({ response: { data: { code: 'not_own_trail' } } })).toBe('not_own_trail'); + expect(wandererErrorCode({ response: { data: {} } })).toBe('unknown'); + expect(wandererErrorCode(new Error('boom'))).toBe('unknown'); + }); +}); diff --git a/client/src/repo/wandererRepo.ts b/client/src/repo/wandererRepo.ts new file mode 100644 index 000000000..c38c4e107 --- /dev/null +++ b/client/src/repo/wandererRepo.ts @@ -0,0 +1,51 @@ +import type { WandererErrorCode, WandererImportRequest, WandererSearchRequest } from '@trek/shared'; +import { wandererApi } from '../api/wanderer'; +import { offlineDb } from '../db/offlineDb'; +import { isEffectivelyOffline } from '../sync/networkMode'; + +/** + * wanderer, deliberately online-only: the connection is the user's own and + * every read goes to their instance through the server, so there is nothing a + * device could answer offline. What the import writes is an ordinary tour, + * cached like every other the moment it comes back. + */ +export const wandererRepo = { + getSettings: wandererApi.getSettings, + saveSettings: wandererApi.saveSettings, + disconnect: wandererApi.disconnect, + test: wandererApi.test, + + async search(tripId: number, body: WandererSearchRequest, signal?: AbortSignal) { + if (isEffectivelyOffline()) throw new WandererOfflineError(); + return await wandererApi.search(tripId, body, signal); + }, + + async preview(tripId: number, trailId: string, signal?: AbortSignal) { + if (isEffectivelyOffline()) throw new WandererOfflineError(); + return await wandererApi.preview(tripId, trailId, signal); + }, + + async importTrail(tripId: number, body: WandererImportRequest) { + if (isEffectivelyOffline()) throw new WandererOfflineError(); + const result = await wandererApi.importTrail(tripId, body); + await offlineDb.tours.put({ ...result.tour, trip_id: tripId }); + return result; + }, +}; + +/** Thrown instead of a request while the app is offline, so the caller can say so. */ +export class WandererOfflineError extends Error { + constructor() { + super('offline'); + this.name = 'WandererOfflineError'; + } +} + +/** The reason code of a failed wanderer call: the server's own, or what the failure itself says. */ +export function wandererErrorCode( + error: unknown +): WandererErrorCode | 'offline' | 'preview_stale' | 'already_imported' | 'no_track' | 'unknown' { + if (error instanceof WandererOfflineError) return 'offline'; + const code = (error as { response?: { data?: { code?: unknown } } })?.response?.data?.code; + return typeof code === 'string' ? (code as WandererErrorCode) : 'unknown'; +} diff --git a/client/src/store/addonStore.ts b/client/src/store/addonStore.ts index 54fe0f10d..c5c752a8f 100644 --- a/client/src/store/addonStore.ts +++ b/client/src/store/addonStore.ts @@ -25,6 +25,8 @@ interface Addon { interface AddonState { addons: Addon[] bagTracking: boolean + /** Tours is on and the admin allows wanderer under it. */ + toursWanderer: boolean loaded: boolean loadAddons: () => Promise isEnabled: (id: string) => boolean @@ -33,12 +35,16 @@ interface AddonState { export const useAddonStore = create((set, get) => ({ addons: [], bagTracking: false, + toursWanderer: false, loaded: false, loadAddons: async () => { try { const data = await addonsApi.enabled() - set({ addons: data.addons || [], bagTracking: !!data.bagTracking, loaded: true }) + set({ addons: data.addons || [], bagTracking: !!data.bagTracking, + toursWanderer: !!data.toursWanderer, + loaded: true, + }) } catch { set({ loaded: true }) } diff --git a/client/tests/helpers/filePicker.ts b/client/tests/helpers/filePicker.ts new file mode 100644 index 000000000..0f63c2d72 --- /dev/null +++ b/client/tests/helpers/filePicker.ts @@ -0,0 +1,30 @@ +import { fireEvent } from '@testing-library/react'; +import { vi } from 'vitest'; + +/** + * Presses a control that opens the browser's file picker by building an + * `` and clicking it, and hands that picker `files` the way + * a user choosing them would. Returns the picker, or null when the press built none. + */ +export function pickFiles(control: HTMLElement, files: File[]): HTMLInputElement | null { + const createElement = document.createElement.bind(document); + let picker: HTMLInputElement | null = null; + const spy = vi.spyOn(document, 'createElement').mockImplementation((( + tagName: string, + options?: ElementCreationOptions + ) => { + const element = createElement(tagName, options); + if (tagName.toLowerCase() === 'input') { + picker = element as HTMLInputElement; + vi.spyOn(picker, 'click').mockImplementation(() => undefined); + } + return element; + }) as typeof document.createElement); + fireEvent.click(control); + spy.mockRestore(); + if (!picker) return null; + const input: HTMLInputElement = picker; + Object.defineProperty(input, 'files', { value: files, configurable: true }); + fireEvent.change(input); + return input; +} diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx index b06d5f98b..39100d9b5 100644 --- a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx @@ -1,4 +1,3 @@ -// FE-MOB-AADD-001 to FE-MOB-AADD-037 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import { delay, http, HttpResponse } from 'msw'; @@ -764,4 +763,22 @@ describe('MAdminAddonManager', () => { act(() => document.documentElement.classList.add('dark')); await waitFor(() => expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg')); }); + it('FE-MOB-AADD-030: wanderer hangs off an enabled Tours addon and saves its switch', async () => { + const user = userEvent.setup(); + const puts: unknown[] = []; + server.use( + addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]), + http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false })), + http.put('/api/admin/tours-wanderer', async ({ request }) => { + puts.push(await request.json()); + return HttpResponse.json({ enabled: true }); + }) + ); + render(); + + const toggle = await screen.findByRole('switch', { name: 'wanderer' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + await user.click(toggle); + await waitFor(() => expect(puts).toEqual([{ enabled: true }])); + }); }); diff --git a/client/tests/unit/stores/addonStore.test.ts b/client/tests/unit/stores/addonStore.test.ts index d195b4456..19479ec5e 100644 --- a/client/tests/unit/stores/addonStore.test.ts +++ b/client/tests/unit/stores/addonStore.test.ts @@ -31,6 +31,16 @@ describe('addonStore', () => { await useAddonStore.getState().loadAddons(); expect(useAddonStore.getState().bagTracking).toBe(true); }); + + it('captures whether wanderer may be used under Tours', async () => { + server.use(http.get('/api/addons', () => HttpResponse.json({ toursWanderer: true, addons: [] }))); + await useAddonStore.getState().loadAddons(); + expect(useAddonStore.getState().toursWanderer).toBe(true); + + server.use(http.get('/api/addons', () => HttpResponse.json({ addons: [] }))); + await useAddonStore.getState().loadAddons(); + expect(useAddonStore.getState().toursWanderer).toBe(false); + }); }); describe('FE-ADDON-002: isEnabled returns true for known addon', () => { diff --git a/server/scripts/format-baseline.json b/server/scripts/format-baseline.json index e1ce926b3..2b858159d 100644 --- a/server/scripts/format-baseline.json +++ b/server/scripts/format-baseline.json @@ -20,7 +20,6 @@ "src/nest/plugins/runtime/plugin-sdk.ts", "src/nest/reservations/reservations.mcp.ts", "src/nest/reservations/reservations.service.ts", - "src/nest/trips/trips.service.ts", "src/nest/vacay/vacay.service.ts", "tests/unit/db/repositories/Places.repository.test.ts", "tests/unit/nest/atlas.service.test.ts", diff --git a/server/scripts/repository-owners.json b/server/scripts/repository-owners.json index a0967fff1..0214921e4 100644 --- a/server/scripts/repository-owners.json +++ b/server/scripts/repository-owners.json @@ -104,6 +104,7 @@ "Tags": "tags", "TodoCategoryAssignees": "todo", "TodoItems": "todo", + "TourSources": "tours", "TourTypes": "tours", "TourWaypoints": "tours", "Tours": "tours", @@ -131,6 +132,7 @@ "VacayYears": "vacay", "VisitedCountries": "atlas", "VisitedRegions": "atlas", + "WandererConnections": "wanderer", "WebauthnChallenges": "auth", "WebauthnCredentials": "auth" } diff --git a/server/src/db/entities/Places.entity.ts b/server/src/db/entities/Places.entity.ts index 6bb49853a..3d98b9231 100644 --- a/server/src/db/entities/Places.entity.ts +++ b/server/src/db/entities/Places.entity.ts @@ -69,6 +69,7 @@ export class Places { reservations_collection = new Collection(this); roadtrip_day_tracks_collection = new Collection(this); tours: Ref | null = null; + tours_collection = new Collection(this); trip_files_collection = new Collection(this); } @@ -125,6 +126,7 @@ export const PlacesSchema = defineEntity({ reservations_collection: () => p.oneToMany(Reservations).mappedBy('place').hidden(), roadtrip_day_tracks_collection: () => p.oneToMany(RoadtripDayTracks).mappedBy('place').hidden(), tours: () => p.oneToOne(Tours).ref().mappedBy('place').hidden(), + tours_collection: () => p.oneToMany(Tours).mappedBy('derivedFromPlace').hidden(), trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('place').hidden(), }, }); diff --git a/server/src/db/entities/TourSources.entity.ts b/server/src/db/entities/TourSources.entity.ts new file mode 100644 index 000000000..f04fc3e03 --- /dev/null +++ b/server/src/db/entities/TourSources.entity.ts @@ -0,0 +1,47 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TourSourcesRepository } from '../repositories/TourSources.repository'; +import { DbTimestampType } from '../types'; +import { Tours } from './Tours.entity'; +import { Users } from './Users.entity'; + +export class TourSources { + [EntityRepositoryType]?: TourSourcesRepository; + [PrimaryKeyProp]?: 'place'; + place?: Ref | null; + place_id?: number | null; + provider!: string; + instance!: string; + external_id!: string; + external_url?: string | null; + external_updated?: string | null; + content_hash!: string; + local_hash!: string; + imported_at?: string | null; + imported_by?: number | null; + importedByRef?: Ref | null; +} + +export const TourSourcesSchema = defineEntity({ + class: TourSources, + repository: () => TourSourcesRepository, + indexes: [ + { + name: 'idx_tour_sources_external', + properties: ['provider', 'instance', 'external_id'], + }, + ], + properties: { + place: () => p.oneToOne(Tours).primary().ref().name('place_id').deleteRule('cascade').nullable().hidden(), + place_id: p.integer().nullable().persist(false), + provider: p.text(), + instance: p.text(), + external_id: p.text(), + external_url: p.text().nullable(), + external_updated: p.text().nullable(), + content_hash: p.text(), + local_hash: p.text(), + imported_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + imported_by: p.integer().nullable().persist(false).index('idx_tour_sources_imported_by'), + importedByRef: () => p.manyToOne(Users).ref().joinColumn('imported_by').nullable().hidden().index('idx_tour_sources_imported_by'), + }, +}); diff --git a/server/src/db/entities/Tours.entity.ts b/server/src/db/entities/Tours.entity.ts index 25aced850..8bcc88f04 100644 --- a/server/src/db/entities/Tours.entity.ts +++ b/server/src/db/entities/Tours.entity.ts @@ -1,6 +1,7 @@ import { Collection, EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; import { ToursRepository } from '../repositories/Tours.repository'; import { Places } from './Places.entity'; +import { TourSources } from './TourSources.entity'; import { TourTypes } from './TourTypes.entity'; import { TourWaypoints } from './TourWaypoints.entity'; @@ -21,13 +22,24 @@ export class Tours { max_hiking_difficulty?: number | null = 2; planned_duration_minutes?: number | null; break_additional_minutes?: number | null; + derivedFromPlace?: Ref | null; + derived_from_place_id?: number | null; + recorded_route_geometry?: string | null; tourTypeRef!: Ref; + tour_sources: Ref | null = null; tour_waypoints_collection = new Collection(this); } export const ToursSchema = defineEntity({ class: Tours, repository: () => ToursRepository, + indexes: [ + { + name: 'idx_tours_derived_from_place_id', + where: 'derived_from_place_id IS NOT NULL', + properties: ['derived_from_place_id'], + }, + ], properties: { place: () => p.oneToOne(Places).primary().ref().deleteRule('cascade').nullable().hidden(), place_id: p.integer().nullable().persist(false), @@ -43,7 +55,11 @@ export const ToursSchema = defineEntity({ max_hiking_difficulty: p.integer().nullable(), planned_duration_minutes: p.integer().nullable(), break_additional_minutes: p.integer().nullable(), + derivedFromPlace: () => p.manyToOne(Places).ref().nullable().hidden(), + derived_from_place_id: p.integer().nullable().persist(false), + recorded_route_geometry: p.text().nullable(), tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'), + tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(), tour_waypoints_collection: () => p.oneToMany(TourWaypoints).mappedBy('place').hidden(), }, }); diff --git a/server/src/db/entities/Users.entity.ts b/server/src/db/entities/Users.entity.ts index b14c8803e..21f9f032d 100644 --- a/server/src/db/entities/Users.entity.ts +++ b/server/src/db/entities/Users.entity.ts @@ -51,6 +51,7 @@ import { ShareTokens } from './ShareTokens.entity'; import { Tags } from './Tags.entity'; import { TodoCategoryAssignees } from './TodoCategoryAssignees.entity'; import { TodoItems } from './TodoItems.entity'; +import { TourSources } from './TourSources.entity'; import { TrekPhotos } from './TrekPhotos.entity'; import { TripAlbumLinks } from './TripAlbumLinks.entity'; import { TripDocumentLinks } from './TripDocumentLinks.entity'; @@ -70,6 +71,7 @@ import { VacayUserSettings } from './VacayUserSettings.entity'; import { VacayUserYears } from './VacayUserYears.entity'; import { VisitedCountries } from './VisitedCountries.entity'; import { VisitedRegions } from './VisitedRegions.entity'; +import { WandererConnections } from './WandererConnections.entity'; import { WebauthnChallenges } from './WebauthnChallenges.entity'; import { WebauthnCredentials } from './WebauthnCredentials.entity'; @@ -174,6 +176,7 @@ export class Users { tags_collection = new Collection(this); todo_category_assignees_collection = new Collection(this); todo_items_collection = new Collection(this); + tour_sources_collection = new Collection(this); trek_photos_collection = new Collection(this); trip_album_links_collection = new Collection(this); trip_document_links_collection = new Collection(this); @@ -195,6 +198,7 @@ export class Users { vacay_user_years_collection = new Collection(this); visited_countries_collection = new Collection(this); visited_regions_collection = new Collection(this); + wanderer_connections: Ref | null = null; webauthn_challenges_collection = new Collection(this); webauthn_credentials_collection = new Collection(this); } @@ -315,6 +319,7 @@ export const UsersSchema = defineEntity({ tags_collection: () => p.oneToMany(Tags).mappedBy('user').hidden(), todo_category_assignees_collection: () => p.oneToMany(TodoCategoryAssignees).mappedBy('user').hidden(), todo_items_collection: () => p.oneToMany(TodoItems).mappedBy('assignedUser').hidden(), + tour_sources_collection: () => p.oneToMany(TourSources).mappedBy('importedByRef').hidden(), trek_photos_collection: () => p.oneToMany(TrekPhotos).mappedBy('owner').hidden(), trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('user').hidden(), trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('createdByRef').hidden(), @@ -336,6 +341,7 @@ export const UsersSchema = defineEntity({ vacay_user_years_collection: () => p.oneToMany(VacayUserYears).mappedBy('user').hidden(), visited_countries_collection: () => p.oneToMany(VisitedCountries).mappedBy('user').hidden(), visited_regions_collection: () => p.oneToMany(VisitedRegions).mappedBy('user').hidden(), + wanderer_connections: () => p.oneToOne(WandererConnections).ref().mappedBy('user').hidden(), webauthn_challenges_collection: () => p.oneToMany(WebauthnChallenges).mappedBy('user').hidden(), webauthn_credentials_collection: () => p.oneToMany(WebauthnCredentials).mappedBy('user').hidden(), }, diff --git a/server/src/db/entities/WandererConnections.entity.ts b/server/src/db/entities/WandererConnections.entity.ts new file mode 100644 index 000000000..3ce7a226f --- /dev/null +++ b/server/src/db/entities/WandererConnections.entity.ts @@ -0,0 +1,36 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { WandererConnectionsRepository } from '../repositories/WandererConnections.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class WandererConnections { + [EntityRepositoryType]?: WandererConnectionsRepository; + [PrimaryKeyProp]?: 'user'; + user?: Ref | null; + user_id?: number | null; + url!: string; + token?: string | null; + allow_insecure_tls: number & Opt = 0; + last_checked_at?: string | null; + last_check_ok?: number | null; + last_check_error?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export const WandererConnectionsSchema = defineEntity({ + class: WandererConnections, + repository: () => WandererConnectionsRepository, + properties: { + user: () => p.oneToOne(Users).primary().ref().deleteRule('cascade').nullable().hidden(), + user_id: p.integer().nullable().persist(false), + url: p.text(), + token: p.text().nullable(), + allow_insecure_tls: p.integer().default(0), + last_checked_at: p.text().nullable(), + last_check_ok: p.integer().nullable(), + last_check_error: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/index.ts b/server/src/db/entities/index.ts index 81226bc0a..eb04fe91a 100644 --- a/server/src/db/entities/index.ts +++ b/server/src/db/entities/index.ts @@ -103,6 +103,7 @@ import { ShareTokensSchema } from './ShareTokens.entity'; import { TagsSchema } from './Tags.entity'; import { TodoCategoryAssigneesSchema } from './TodoCategoryAssignees.entity'; import { TodoItemsSchema } from './TodoItems.entity'; +import { TourSourcesSchema } from './TourSources.entity'; import { TourTypesSchema } from './TourTypes.entity'; import { TourWaypointsSchema } from './TourWaypoints.entity'; import { ToursSchema } from './Tours.entity'; @@ -130,6 +131,7 @@ import { VacayUserYearsSchema } from './VacayUserYears.entity'; import { VacayYearsSchema } from './VacayYears.entity'; import { VisitedCountriesSchema } from './VisitedCountries.entity'; import { VisitedRegionsSchema } from './VisitedRegions.entity'; +import { WandererConnectionsSchema } from './WandererConnections.entity'; import { WebauthnChallengesSchema } from './WebauthnChallenges.entity'; import { WebauthnCredentialsSchema } from './WebauthnCredentials.entity'; @@ -238,6 +240,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [ TagsSchema, TodoCategoryAssigneesSchema, TodoItemsSchema, + TourSourcesSchema, TourTypesSchema, TourWaypointsSchema, ToursSchema, @@ -265,6 +268,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [ VacayYearsSchema, VisitedCountriesSchema, VisitedRegionsSchema, + WandererConnectionsSchema, WebauthnChallengesSchema, WebauthnCredentialsSchema, ]; diff --git a/server/src/db/kysely/TourSources.table.ts b/server/src/db/kysely/TourSources.table.ts new file mode 100644 index 000000000..75171bd23 --- /dev/null +++ b/server/src/db/kysely/TourSources.table.ts @@ -0,0 +1,16 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. +import type { InsertOptional } from './columns'; + +export interface TourSourcesTable { + place_id: InsertOptional; + provider: string; + instance: string; + external_id: string; + external_url: InsertOptional; + external_updated: InsertOptional; + content_hash: string; + local_hash: string; + imported_at: InsertOptional; + imported_by: InsertOptional; +} diff --git a/server/src/db/kysely/Tours.table.ts b/server/src/db/kysely/Tours.table.ts index 1bea5791b..46b3012cb 100644 --- a/server/src/db/kysely/Tours.table.ts +++ b/server/src/db/kysely/Tours.table.ts @@ -16,4 +16,6 @@ export interface ToursTable { max_hiking_difficulty: InsertOptional; planned_duration_minutes: InsertOptional; break_additional_minutes: InsertOptional; + derived_from_place_id: InsertOptional; + recorded_route_geometry: InsertOptional; } diff --git a/server/src/db/kysely/WandererConnections.table.ts b/server/src/db/kysely/WandererConnections.table.ts new file mode 100644 index 000000000..4d417ef88 --- /dev/null +++ b/server/src/db/kysely/WandererConnections.table.ts @@ -0,0 +1,15 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. +import type { InsertOptional } from './columns'; + +export interface WandererConnectionsTable { + user_id: InsertOptional; + url: string; + token: InsertOptional; + allow_insecure_tls: InsertOptional; + last_checked_at: InsertOptional; + last_check_ok: InsertOptional; + last_check_error: InsertOptional; + created_at: InsertOptional; + updated_at: InsertOptional; +} diff --git a/server/src/db/kysely/db.ts b/server/src/db/kysely/db.ts index ff373dc68..1668f25ba 100644 --- a/server/src/db/kysely/db.ts +++ b/server/src/db/kysely/db.ts @@ -109,6 +109,7 @@ import type { ShareTokensTable } from './ShareTokens.table'; import type { TagsTable } from './Tags.table'; import type { TodoCategoryAssigneesTable } from './TodoCategoryAssignees.table'; import type { TodoItemsTable } from './TodoItems.table'; +import type { TourSourcesTable } from './TourSources.table'; import type { TourTypesTable } from './TourTypes.table'; import type { TourWaypointsTable } from './TourWaypoints.table'; import type { ToursTable } from './Tours.table'; @@ -136,6 +137,7 @@ import type { VacayUserYearsTable } from './VacayUserYears.table'; import type { VacayYearsTable } from './VacayYears.table'; import type { VisitedCountriesTable } from './VisitedCountries.table'; import type { VisitedRegionsTable } from './VisitedRegions.table'; +import type { WandererConnectionsTable } from './WandererConnections.table'; import type { WebauthnChallengesTable } from './WebauthnChallenges.table'; import type { WebauthnCredentialsTable } from './WebauthnCredentials.table'; @@ -250,6 +252,7 @@ export interface DB { tags: TagsTable; todo_category_assignees: TodoCategoryAssigneesTable; todo_items: TodoItemsTable; + tour_sources: TourSourcesTable; tour_types: TourTypesTable; tour_waypoints: TourWaypointsTable; tours: ToursTable; @@ -277,6 +280,7 @@ export interface DB { vacay_years: VacayYearsTable; visited_countries: VisitedCountriesTable; visited_regions: VisitedRegionsTable; + wanderer_connections: WandererConnectionsTable; webauthn_challenges: WebauthnChallengesTable; webauthn_credentials: WebauthnCredentialsTable; } diff --git a/server/src/db/migrations/Migration20200101043400_wanderer_connection_and_tour_sources.ts b/server/src/db/migrations/Migration20200101043400_wanderer_connection_and_tour_sources.ts new file mode 100644 index 000000000..e44297612 --- /dev/null +++ b/server/src/db/migrations/Migration20200101043400_wanderer_connection_and_tour_sources.ts @@ -0,0 +1,53 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * A personal wanderer connection, and where an imported tour came from. + * + * `wanderer_connections` holds one user's instance address and API token, the + * token encrypted like every other integration credential. It is the user's + * own and is never shared with a trip; what a trip gets is the snapshot. + * + * `tour_sources` records, for a tour copied in from an outside source, which + * source it was (the provider, the instance, the trail's id there), when it was + * copied and two fingerprints: the content as the source delivered it, to tell + * on a later import whether the source changed, and the tour as it was saved + * here, to tell whether it was changed since. It is not wanderer-specific, so + * other sources can record their provenance the same way. It goes with its tour. + */ +export class Migration20200101043400_wanderer_connection_and_tour_sources extends Migration { + override name = 'Migration20200101043400_wanderer_connection_and_tour_sources'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS wanderer_connections ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + url TEXT NOT NULL, + token TEXT, + allow_insecure_tls INTEGER NOT NULL DEFAULT 0, + last_checked_at TEXT, + last_check_ok INTEGER, + last_check_error TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + this.addSql(` + CREATE TABLE IF NOT EXISTS tour_sources ( + place_id INTEGER PRIMARY KEY REFERENCES tours(place_id) ON DELETE CASCADE, + provider TEXT NOT NULL, + instance TEXT NOT NULL, + external_id TEXT NOT NULL, + external_url TEXT, + external_updated TEXT, + content_hash TEXT NOT NULL, + local_hash TEXT NOT NULL, + imported_at DATETIME DEFAULT CURRENT_TIMESTAMP, + imported_by INTEGER REFERENCES users(id) ON DELETE SET NULL + ) + `); + this.addSql( + 'CREATE INDEX IF NOT EXISTS idx_tour_sources_external ON tour_sources(provider, instance, external_id)', + ); + this.addSql('CREATE INDEX IF NOT EXISTS idx_tour_sources_imported_by ON tour_sources(imported_by)'); + } +} diff --git a/server/src/db/migrations/Migration20200101043500_wanderer_difficulty_on_the_sac_scale.ts b/server/src/db/migrations/Migration20200101043500_wanderer_difficulty_on_the_sac_scale.ts new file mode 100644 index 000000000..c93eea273 --- /dev/null +++ b/server/src/db/migrations/Migration20200101043500_wanderer_difficulty_on_the_sac_scale.ts @@ -0,0 +1,22 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Tours imported from wanderer carry wanderer's own word for how hard they are + * (`difficulty`: easy, moderate, difficult), and tours show one scale only, the + * SAC grade in `max_hiking_difficulty`. An import now writes both; this gives + * the imports made before that their grade the same way: easy is T1, moderate + * T2, difficult T3. A grade someone already set is left as it is. + */ +export class Migration20200101043500_wanderer_difficulty_on_the_sac_scale extends Migration { + override name = 'Migration20200101043500_wanderer_difficulty_on_the_sac_scale'; + + override up(): void { + this.addSql(` + UPDATE tours + SET max_hiking_difficulty = CASE difficulty WHEN 'easy' THEN 1 WHEN 'moderate' THEN 2 WHEN 'difficult' THEN 3 END + WHERE wanderer_ref IS NOT NULL + AND max_hiking_difficulty IS NULL + AND difficulty IN ('easy', 'moderate', 'difficult') + `); + } +} diff --git a/server/src/db/migrations/Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from.ts b/server/src/db/migrations/Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from.ts new file mode 100644 index 000000000..9119a89dd --- /dev/null +++ b/server/src/db/migrations/Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from.ts @@ -0,0 +1,18 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * A tour planned from an imported one (a GPX file or a wanderer snapshot, + * re-routed through control points taken from its track) remembers which import + * it came from, so the tour can say so. The import stays as it was; deleting it + * leaves the derived tour standing and only forgets where it came from. + */ +export class Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from extends Migration { + override name = 'Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from'; + + override up(): void { + this.addSql('ALTER TABLE tours ADD COLUMN derived_from_place_id INTEGER REFERENCES places(id) ON DELETE SET NULL'); + this.addSql( + 'CREATE INDEX IF NOT EXISTS idx_tours_derived_from_place_id ON tours(derived_from_place_id) WHERE derived_from_place_id IS NOT NULL', + ); + } +} diff --git a/server/src/db/migrations/Migration20200101043700_an_import_planned_over_keeps_its_recorded_track.ts b/server/src/db/migrations/Migration20200101043700_an_import_planned_over_keeps_its_recorded_track.ts new file mode 100644 index 000000000..ac1c3b348 --- /dev/null +++ b/server/src/db/migrations/Migration20200101043700_an_import_planned_over_keeps_its_recorded_track.ts @@ -0,0 +1,15 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * An import (a GPX file or a wanderer snapshot) can be planned over in place: + * the tour keeps its id, its days and its notes, and gets waypoints and a line + * routed through them. The track it was recorded with is kept here the first + * time that happens, so the tour can go back to it whenever its owner wants. + */ +export class Migration20200101043700_an_import_planned_over_keeps_its_recorded_track extends Migration { + override name = 'Migration20200101043700_an_import_planned_over_keeps_its_recorded_track'; + + override up(): void { + this.addSql('ALTER TABLE tours ADD COLUMN recorded_route_geometry TEXT'); + } +} diff --git a/server/src/db/repositories/TourSources.repository.ts b/server/src/db/repositories/TourSources.repository.ts new file mode 100644 index 000000000..abe0982bd --- /dev/null +++ b/server/src/db/repositories/TourSources.repository.ts @@ -0,0 +1,79 @@ +import type { TourSources } from '../entities/TourSources.entity'; +import type { DB } from '../kysely/db'; +import { TrekRepository } from './_shared/trek-repository'; + +/** Where an imported tour came from, as it is written once at the import. */ +export interface TourSourceInsert { + place_id: number; + provider: string; + instance: string; + external_id: string; + external_url: string | null; + external_updated: string | null; + content_hash: string; + local_hash: string; + imported_by: number; +} + +/** A snapshot of an outside source in a trip, with the tour as it stands now. */ +export interface TourSnapshotRow { + place_id: number; + external_id: string; + content_hash: string; + local_hash: string; + imported_at: string | null; + name: string; + description: string | null; + route_geometry: string | null; +} + +type SourcesDB = Pick; + +/** + * `tour_sources`: the provenance of tours copied in from outside, one row per + * tour, gone with the tour. The source's own id is not unique here on purpose: + * importing a changed trail again makes a second snapshot beside the first. + */ +export class TourSourcesRepository extends TrekRepository { + private db() { + return this.kysely(); + } + + /** Written in the same transaction as the tour it describes. */ + async insertSource(row: TourSourceInsert): Promise { + await this.db().insertInto('tour_sources').values(row).execute(); + } + + /** + * Every snapshot in the trip of the given trails of one instance, newest + * first, with the tour's current name and line for comparing. + */ + async listSnapshots( + tripId: number, + provider: string, + instance: string, + externalIds: readonly string[], + ): Promise { + if (externalIds.length === 0) return []; + return await this.db() + .selectFrom('tour_sources as s') + .innerJoin('places as p', 'p.id', 's.place_id') + .select([ + 's.place_id', + 's.external_id', + 's.content_hash', + 's.local_hash', + 's.imported_at', + 'p.name', + 'p.description', + 'p.route_geometry', + ]) + .where('p.trip_id', '=', tripId) + .where('s.provider', '=', provider) + .where('s.instance', '=', instance) + .where('s.external_id', 'in', [...externalIds]) + .orderBy('s.imported_at', 'desc') + .orderBy('s.place_id', 'desc') + .execute(); + } +} diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 5715783cb..6ef5bf4f0 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -25,6 +25,8 @@ export interface TourListRow { wanderer_ref: string | null; match_confidence: number | null; max_hiking_difficulty: number | null; + derived_from_place_id: number | null; + has_recorded_track: number; planned: number; has_waypoints: number; } @@ -44,6 +46,8 @@ export interface TourRow { match_confidence: number | null; created_at: string | null; max_hiking_difficulty: number | null; + derived_from_place_id: number | null; + recorded_route_geometry: string | null; } const _tourRowKeys: AssertRowKeys = true; @@ -105,6 +109,8 @@ export class ToursRepository extends TrekRepository { 't.wanderer_ref as wanderer_ref', 't.match_confidence as match_confidence', 't.max_hiking_difficulty as max_hiking_difficulty', + 't.derived_from_place_id as derived_from_place_id', + eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'), eb .exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id')) .as('planned'), @@ -184,8 +190,21 @@ export class ToursRepository extends TrekRepository { } /** TO7 — `INSERT INTO tours (place_id, tour_type, distance, ...) VALUES (...)`. */ - async insertTour(input: TourInsert): Promise { + /** + * `wanderer_ref` and `difficulty` are set only by an import from a source that + * carries them, `derived_from_place_id` only by a tour planned from an import. + */ + async insertTour( + input: TourInsert & { + wanderer_ref?: string | null; + difficulty?: string | null; + derived_from_place_id?: number | null; + }, + ): Promise { await this.insert({ + derivedFromPlace: input.derived_from_place_id ?? null, + wanderer_ref: input.wanderer_ref ?? null, + difficulty: input.difficulty ?? null, place: input.place_id, tourTypeRef: input.tour_type, distance: input.distance, @@ -238,10 +257,63 @@ export class ToursRepository extends TrekRepository { .executeTakeFirst(); return Number(result.numUpdatedRows ?? 0) > 0; } + + /** + * Keeps an import's recorded line before it is planned over for the first + * time: copies the place's line into the tour while the tour has no + * waypoints and nothing kept yet. One statement, so it cannot keep a line + * that a concurrent edit already replaced. + */ + async keepRecordedTrack(place_id: number): Promise { + await this.db() + .updateTable('tours') + .set((eb) => ({ + recorded_route_geometry: eb + .selectFrom('places') + .select('places.route_geometry') + .where('places.id', '=', place_id), + })) + .where('place_id', '=', place_id) + .where('recorded_route_geometry', 'is', null) + .where((eb) => + eb.not( + eb.exists( + eb.selectFrom('tour_waypoints').select('tour_waypoints.id').where('tour_waypoints.place_id', '=', place_id), + ), + ), + ) + .execute(); + } + + /** The recorded line kept for a tour of the trip, or null when it has none. */ + async findRecordedTrack(trip_id: number, place_id: number): Promise { + const row = await this.db() + .selectFrom('tours as t') + .innerJoin('places as p', 'p.id', 't.place_id') + .select('t.recorded_route_geometry') + .where('t.place_id', '=', place_id) + .where('p.trip_id', '=', trip_id) + .executeTakeFirst(); + return row?.recorded_route_geometry ?? null; + } + + /** Forgets the kept line once the tour has gone back to it. */ + async clearRecordedTrack(place_id: number): Promise { + await this.nativeUpdate({ place: place_id }, { recorded_route_geometry: null }); + } } function toListRow< - R extends Omit & { planned: unknown; has_waypoints: unknown }, + R extends Omit & { + planned: unknown; + has_waypoints: unknown; + has_recorded_track: unknown; + }, >(r: R): TourListRow { - return { ...r, planned: Number(r.planned), has_waypoints: Number(r.has_waypoints) }; + return { + ...r, + planned: Number(r.planned), + has_waypoints: Number(r.has_waypoints), + has_recorded_track: Number(r.has_recorded_track), + }; } diff --git a/server/src/db/repositories/WandererConnections.repository.ts b/server/src/db/repositories/WandererConnections.repository.ts new file mode 100644 index 000000000..f452fed0e --- /dev/null +++ b/server/src/db/repositories/WandererConnections.repository.ts @@ -0,0 +1,87 @@ +import { currentTimestampKysely } from '../dialect/kysely-functions'; +import { currentTimestamp } from '../dialect/sql-functions'; +import type { WandererConnections } from '../entities/WandererConnections.entity'; +import type { DB } from '../kysely/db'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * One user's wanderer connection as stored. `token` is the still-encrypted + * value; the service decrypts it for a call and never hands it on. + */ +export interface WandererConnectionRow { + user_id: number; + url: string; + token: string | null; + allow_insecure_tls: number; + last_checked_at: string | null; + last_check_ok: number | null; + last_check_error: string | null; +} + +/** + * `wanderer_connections`: one row per user who connected an instance, the + * user id its primary key. Only the owner's own requests read or write it. + */ +export class WandererConnectionsRepository extends TrekRepository { + private db() { + return this.kysely>(); + } + + private platform() { + return this.getEntityManager().getPlatform(); + } + + async findRow(userId: number): Promise { + const row = await this.db() + .selectFrom('wanderer_connections') + .select(['user_id', 'url', 'token', 'allow_insecure_tls', 'last_checked_at', 'last_check_ok', 'last_check_error']) + .where('user_id', '=', userId) + .executeTakeFirst(); + return row ?? null; + } + + /** + * Write the address and the TLS choice, and the token when one is given: + * `undefined` keeps the stored token, `null` drops it. A changed address + * clears what the last check said, since it said it about another server. + */ + async save( + userId: number, + data: { url: string; allowInsecureTls: boolean; token?: string | null; hostChanged: boolean }, + ): Promise { + const platform = this.platform(); + const tokenColumns = data.token !== undefined ? { token: data.token } : {}; + const checkColumns = data.hostChanged ? { last_checked_at: null, last_check_ok: null, last_check_error: null } : {}; + await this.db() + .insertInto('wanderer_connections') + .values({ + user_id: userId, + url: data.url, + allow_insecure_tls: data.allowInsecureTls ? 1 : 0, + ...tokenColumns, + updated_at: currentTimestampKysely(platform), + }) + .onConflict((oc) => + oc.column('user_id').doUpdateSet({ + url: data.url, + allow_insecure_tls: data.allowInsecureTls ? 1 : 0, + ...tokenColumns, + ...checkColumns, + updated_at: () => currentTimestampKysely(platform), + }), + ) + .execute(); + } + + /** What the last test or call against the instance said, for the settings card. */ + async recordCheck(userId: number, ok: boolean, error: string | null): Promise { + await this.nativeUpdate( + { user: userId }, + { last_checked_at: currentTimestamp(this.platform()), last_check_ok: ok ? 1 : 0, last_check_error: error }, + ); + } + + async remove(userId: number): Promise { + await this.nativeDelete({ user: userId }); + } +} diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts index 7132bd947..52076db9e 100644 --- a/server/src/nest/addons/addons.service.ts +++ b/server/src/nest/addons/addons.service.ts @@ -67,6 +67,22 @@ export class AddonsService { return { enabled: !!enabled }; } + /** The admin's switch for wanderer under Tours, as stored; whether Tours is on is asked apart. */ + async getToursWanderer() { + const value = await readAppSetting(this.appSettings, 'tours_wanderer_enabled'); + return { enabled: value === 'true' }; + } + + async updateToursWanderer(enabled: boolean) { + await this.appSettings.setValue('tours_wanderer_enabled', enabled ? 'true' : 'false'); + return { enabled: !!enabled }; + } + + /** wanderer may be used: Tours is on and the admin allowed it under Tours. */ + async isWandererAvailable(): Promise { + return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled; + } + /** * `AppSettingsRepository.getValues` drops a key from the returned `Map` * both when no row exists AND when the row's `value` is `NULL` (Task 0 @@ -146,6 +162,7 @@ export class AddonsService { return { collabFeatures: await this.getCollabFeatures(), bagTracking: (await this.getBagTracking()).enabled, + toursWanderer: await this.isWandererAvailable(), addons: [ // The repository row carries every scalar column (description, config, // sort_order included); the legacy statement selected only these five, diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 10de38aa5..4559388c2 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -10,6 +10,7 @@ import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; import { RegistrationInvitesService } from '../auth/registration-invites.service'; import { KitineraryExtractorService } from '../booking-import/kitinerary-extractor.service'; import { ManagedForbidden } from '../common/managed'; +import { ResponseContract } from '../common/response-contract'; import { NotificationsService } from '../notifications/notifications.service'; import { OauthService } from '../oauth/oauth.service'; import { PluginRuntimeService } from '../plugins/plugin-runtime.service'; @@ -41,6 +42,7 @@ import { Req, UseGuards, } from '@nestjs/common'; +import { adminFeatureToggleResponseSchema } from '@trek/shared'; import type { Request } from 'express'; @@ -283,6 +285,25 @@ export class AdminController { return result; } + @Get('tours-wanderer') + @ResponseContract(adminFeatureToggleResponseSchema) + getToursWanderer() { + return this.addons.getToursWanderer(); + } + + @Put('tours-wanderer') + @ResponseContract(adminFeatureToggleResponseSchema) + async updateToursWanderer(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { + const result = await this.addons.updateToursWanderer(body.enabled); + await this.audit.writeAudit({ + userId: user.id, + action: 'admin.tours_wanderer', + ip: getClientIp(req), + details: { enabled: result.enabled }, + }); + return result; + } + @Get('places-photos') getPlacesPhotos() { return this.addons.getPlacesPhotos(); diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 46b2b342e..e8f161095 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -92,6 +92,7 @@ import { TransitModule } from './transit/transit.module'; import { TripInviteModule } from './trip-invite/trip-invite.module'; import { TripsModule } from './trips/trips.module'; import { VacayModule } from './vacay/vacay.module'; +import { WandererModule } from './wanderer/wanderer.module'; import { WeatherModule } from './weather/weather.module'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module } from '@nestjs/common'; @@ -152,6 +153,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; PlacesModule, RoadtripModule, ToursModule, + WandererModule, FerratasModule, TripsModule, CollabModule, diff --git a/server/src/nest/common/app-settings.registry.ts b/server/src/nest/common/app-settings.registry.ts index 27ff2a7a0..7dae4bb36 100644 --- a/server/src/nest/common/app-settings.registry.ts +++ b/server/src/nest/common/app-settings.registry.ts @@ -106,6 +106,8 @@ export const APP_SETTINGS = { places_enrich_enabled: { schema: flag, default: 'true', publicConfig: true }, place_shadow_enabled: { schema: flag, default: 'false', publicConfig: true }, bag_tracking_enabled: { schema: flag, default: 'false' }, + // Off until an admin allows it: every user's wanderer calls go out to an address of their choosing. + tours_wanderer_enabled: { schema: flag, default: 'false' }, route_usage_enabled: { schema: flag, default: 'false' }, airtrail_sync_enabled: { schema: flag, default: 'true' }, airtrail_poll_interval_minutes: { schema: intText, default: null }, diff --git a/server/src/nest/tours/dto/tours-wanderer.dto.ts b/server/src/nest/tours/dto/tours-wanderer.dto.ts new file mode 100644 index 000000000..5cd1a1948 --- /dev/null +++ b/server/src/nest/tours/dto/tours-wanderer.dto.ts @@ -0,0 +1,7 @@ +import { wandererImportRequestSchema, wandererSearchRequestSchema } from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +/** The trip-side wanderer routes' bodies, validated by the global Zod pipe against the shared contracts. */ +export class WandererSearchDto extends createZodDto(wandererSearchRequestSchema) {} +export class WandererImportDto extends createZodDto(wandererImportRequestSchema) {} diff --git a/server/src/nest/tours/tours-wanderer.controller.ts b/server/src/nest/tours/tours-wanderer.controller.ts new file mode 100644 index 000000000..c83d27a53 --- /dev/null +++ b/server/src/nest/tours/tours-wanderer.controller.ts @@ -0,0 +1,54 @@ +import { ADDON_IDS } from '../../addons'; +import type { User } from '../../types'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { CurrentUser } from '../auth-core/current-user.decorator'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; +import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; +import { WandererEnabledGuard } from '../wanderer/wanderer-enabled.guard'; +import { WandererImportDto, WandererSearchDto } from './dto/tours-wanderer.dto'; +import { ToursWandererService } from './tours-wanderer.service'; +import { Body, Controller, Get, Headers, HttpCode, Param, Post, UseGuards } from '@nestjs/common'; +import { wandererImportPreviewSchema, wandererImportResponseSchema, wandererSearchResponseSchema } from '@trek/shared'; + +/** + * `/api/trips/:tripId/tours/wanderer`: the caller's wanderer trails, seen from + * a trip. Searching and previewing run on the caller's own connection, and + * only someone who may add places to the trip gets to look, since looking is + * the first step of importing. The import writes a tour like the GPX import + * does, so it asks the same right. + */ +@Controller('api/trips/:tripId/tours/wanderer') +@UseGuards(AddonGuard, JwtAuthGuard, WandererEnabledGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.TOURS, 'Tours') +export class ToursWandererController { + constructor(private readonly wanderer: ToursWandererService) {} + + @RequirePermission('place_edit') + @Post('search') + @HttpCode(200) + @ResponseContract(wandererSearchResponseSchema) + search(@Param('tripId') tripId: string, @CurrentUser() user: User, @Body() body: WandererSearchDto) { + return this.wanderer.search(tripId, user.id, { q: body.q ?? '', page: body.page ?? 1, bbox: body.bbox }); + } + + @RequirePermission('place_edit') + @Get('trails/:trailId') + @ResponseContract(wandererImportPreviewSchema) + preview(@Param('tripId') tripId: string, @Param('trailId') trailId: string, @CurrentUser() user: User) { + return this.wanderer.preview(tripId, user.id, trailId); + } + + @RequirePermission('place_edit') + @Post('import') + @ResponseContract(wandererImportResponseSchema) + import( + @Param('tripId') tripId: string, + @CurrentUser() user: User, + @Body() body: WandererImportDto, + @Headers('x-socket-id') socketId?: string, + ) { + return this.wanderer.import(tripId, user.id, body, socketId); + } +} diff --git a/server/src/nest/tours/tours-wanderer.service.ts b/server/src/nest/tours/tours-wanderer.service.ts new file mode 100644 index 000000000..75311f583 --- /dev/null +++ b/server/src/nest/tours/tours-wanderer.service.ts @@ -0,0 +1,270 @@ +import { Places } from '../../db/entities/Places.entity'; +import { TourSources } from '../../db/entities/TourSources.entity'; +import { Tours } from '../../db/entities/Tours.entity'; +import type { PlacesRepository } from '../../db/repositories/Places.repository'; +import type { TourSnapshotRow, TourSourcesRepository } from '../../db/repositories/TourSources.repository'; +import type { ToursRepository } from '../../db/repositories/Tours.repository'; +import { thinLine } from '../../utils/polyline'; +import { DomainError } from '../common/domain-error'; +import { toRowId } from '../common/row-id'; +import { UnitOfWork } from '../database/unit-of-work'; +import { PlaceImportService } from '../place-import/place-import.service'; +import { PlacesService } from '../places/places.service'; +import { WandererService } from '../wanderer/wanderer.service'; +import { computeTourMetrics, parseRouteGeometry, type GeometryPoint } from './tours.helpers'; +import { ToursService } from './tours.service'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable, Logger } from '@nestjs/common'; +import type { + WandererImportPreview, + WandererImportRequest, + WandererImportResponse, + WandererSearchRequest, + WandererSearchResponse, +} from '@trek/shared'; +import { wandererDifficultyToSac } from '@trek/shared'; + +import { createHash } from 'node:crypto'; + +const PROVIDER = 'wanderer'; +/** The line the preview draws: the whole tour, thinned for the map. */ +const PREVIEW_LINE_POINTS = 800; + +/** A short stable fingerprint of one part of a trail. */ +function fingerprint(value: unknown): string { + return createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16); +} + +/** Coordinates to five places (a metre) and heights to the metre, so a re-export with other float noise reads the same. */ +function normalizedRoute(points: readonly GeometryPoint[]): number[][] { + return points.map(([lat, lng, ele]) => + ele == null ? [+lat.toFixed(5), +lng.toFixed(5)] : [+lat.toFixed(5), +lng.toFixed(5), Math.round(ele)], + ); +} + +/** What the source delivered, part by part: `name.description.route`. */ +function contentHashOf(name: string, description: string | null, route: readonly GeometryPoint[]): string { + return [fingerprint(name.trim()), fingerprint((description ?? '').trim()), fingerprint(normalizedRoute(route))].join( + '.', + ); +} + +/** The tour as saved here: `name.route`, to tell later whether it was edited. */ +function localHashOf(name: string, route: readonly GeometryPoint[]): string { + return [fingerprint(name.trim()), fingerprint(normalizedRoute(route))].join('.'); +} + +const CONTENT_PARTS = ['name', 'description', 'route'] as const; + +function changedParts(before: string, now: string): Array<(typeof CONTENT_PARTS)[number]> { + const a = before.split('.'); + const b = now.split('.'); + return CONTENT_PARTS.filter((_, i) => a[i] !== b[i]); +} + +/** The trip id the guard already resolved; a malformed one never got this far, and still answers 404. */ +function tripRowId(tripId: string): number { + const tid = toRowId(tripId); + if (tid === null) throw new DomainError(404, 'Trip not found'); + return tid; +} + +/** + * wanderer trails into a trip: the search marked with what the trip already + * holds, the preview of one trail measured from its original GPX and compared + * with an earlier snapshot, and the import that writes the snapshot. + * + * A snapshot is an independent tour. Nothing here updates, replaces or deletes + * one: a changed trail can only be imported again, as a new tour beside the old. + */ +@Injectable() +export class ToursWandererService { + private readonly logger = new Logger(ToursWandererService.name); + + constructor( + private readonly uow: UnitOfWork, + private readonly wanderer: WandererService, + private readonly placeImport: PlaceImportService, + private readonly places: PlacesService, + private readonly tours: ToursService, + @InjectRepository(Tours) private readonly toursRepo: ToursRepository, + @InjectRepository(Places) private readonly placesRepo: PlacesRepository, + @InjectRepository(TourSources) private readonly sources: TourSourcesRepository, + ) {} + + async search( + tripId: string, + userId: number, + request: Required> & Pick, + ): Promise { + const tid = tripRowId(tripId); + const found = await this.wanderer.search(userId, request); + const snapshots = await this.sources.listSnapshots( + tid, + PROVIDER, + found.instance, + found.trails.map((trail) => trail.id), + ); + return { + trails: found.trails.map((trail) => ({ + ...trail, + importedAs: snapshots + .filter((snapshot) => snapshot.external_id === trail.id) + .map((snapshot) => snapshot.place_id), + })), + total: found.total, + page: request.page, + pageSize: found.pageSize, + }; + } + + /** Download and measure one trail, without writing anything. */ + private async fetchTrail(userId: number, trailId: string) { + const summary = await this.wanderer.findTrail(userId, trailId); + if (!summary) + throw new DomainError(404, 'Trail not found', { code: 'wanderer_not_found', details: { code: 'not_found' } }); + const { instance, trail, gpx } = await this.wanderer.download(userId, trailId); + const rows = this.placeImport.readGpx(Buffer.from(gpx, 'utf8'), { + importWaypoints: false, + importRoutes: true, + importTracks: true, + defaultName: trail.name, + }); + // A trail with several segments arrives as several tracks; together they are the one tour. + const route = rows.flatMap((row) => parseRouteGeometry(row.routeGeometry)); + if (route.length < 2) { + throw new DomainError(422, 'The trail has no track or route', { + code: 'wanderer_no_track', + details: { code: 'no_track' }, + }); + } + const description = trail.description?.trim() || null; + // The search index can lag behind an edit; the record itself is what gets imported. + return { + summary: { ...summary, name: trail.name, description }, + instance, + trail, + route, + description, + contentHash: contentHashOf(trail.name, description, route), + }; + } + + /** Every snapshot of the trail in the trip, newest first. */ + private async snapshotsOf(tid: number, instance: string, trailId: string): Promise { + return await this.sources.listSnapshots(tid, PROVIDER, instance, [trailId]); + } + + async preview(tripId: string, userId: number, trailId: string): Promise { + const tid = tripRowId(tripId); + const fetched = await this.fetchTrail(userId, trailId); + const metrics = computeTourMetrics(fetched.route); + const snapshots = await this.snapshotsOf(tid, fetched.instance, trailId); + const latest = snapshots[0]; + const changes = latest ? changedParts(latest.content_hash, fetched.contentHash) : []; + return { + trail: { ...fetched.summary, importedAs: snapshots.map((row) => row.place_id) }, + distanceKm: metrics.distanceKm ?? 0, + elevationGainM: metrics.elevationGainM, + elevationLossM: metrics.elevationLossM, + hasElevation: metrics.hasElevation, + points: fetched.route.length, + line: thinLine(fetched.route, PREVIEW_LINE_POINTS).map(([lat, lng]) => [lat, lng] as [number, number]), + contentHash: fetched.contentHash, + state: latest ? (changes.length === 0 ? 'unchanged' : 'changed') : 'new', + snapshot: latest + ? { + placeId: latest.place_id, + name: latest.name, + importedAt: latest.imported_at ?? '', + // The tour as it stands now against the tour as it was saved at the import. + editedLocally: localHashOf(latest.name, parseRouteGeometry(latest.route_geometry)) !== latest.local_hash, + } + : null, + changes, + }; + } + + /** + * Write the snapshot: the place with the full line, its tour facet and its + * provenance, all or nothing. Refused when the trail is no longer what the + * preview showed, and when the trip holds a snapshot of exactly this content. + */ + async import( + tripId: string, + userId: number, + request: WandererImportRequest, + socketId?: string, + ): Promise { + const tid = tripRowId(tripId); + const trailId = request.trailId; + const fetched = await this.fetchTrail(userId, trailId); + if (fetched.contentHash !== request.contentHash) { + throw new DomainError(409, 'The trail changed since the preview', { + code: 'wanderer_preview_stale', + details: { code: 'preview_stale' }, + }); + } + const same = (await this.snapshotsOf(tid, fetched.instance, trailId)).find( + (row) => row.content_hash === fetched.contentHash, + ); + if (same) { + throw new DomainError(409, 'This trail is already in the trip', { + code: 'wanderer_already_imported', + details: { code: 'already_imported', placeId: same.place_id }, + }); + } + const metrics = computeTourMetrics(fetched.route); + const [startLat, startLng] = fetched.route[0]; + const name = fetched.trail.name.trim().slice(0, 255) || fetched.summary.name; + + const placeId = await this.uow.transactional(async () => { + const id = await this.placesRepo.insertTourPlace({ + trip_id: tid, + name, + lat: startLat, + lng: startLng, + route_geometry: JSON.stringify(fetched.route), + description: fetched.description, + website: null, + }); + await this.toursRepo.insertTour({ + place_id: id, + tour_type: 'hike', + distance: metrics.distanceKm, + elevation_gain: metrics.elevationGainM, + elevation_loss: metrics.elevationLossM, + duration: null, + planned_duration_minutes: null, + break_additional_minutes: null, + match_confidence: metrics.hasElevation ? 1 : 0.3, + // The one scale tours use; the source's own word stays in `difficulty`. + max_hiking_difficulty: wandererDifficultyToSac(fetched.summary.difficulty), + wanderer_ref: `${fetched.instance}#${trailId}`, + difficulty: fetched.summary.difficulty, + }); + await this.sources.insertSource({ + place_id: id, + provider: PROVIDER, + instance: fetched.instance, + external_id: trailId, + external_url: fetched.summary.externalUrl, + external_updated: fetched.trail.updated ?? null, + content_hash: fetched.contentHash, + local_hash: localHashOf(name, fetched.route), + imported_by: userId, + }); + return id; + }); + + const { tour } = await this.tours.getTour(tripId, String(placeId)); + const place = await this.placesRepo.findWithTagsAndRatings(placeId); + try { + this.places.broadcast(tripId, 'tours:changed', { placeIds: [placeId] }, socketId); + if (place) this.places.broadcast(tripId, 'place:created', { place }, socketId); + } catch { + this.logger.warn(`Committed wanderer import ${placeId}: realtime notification failed`); + } + return { tour }; + } +} diff --git a/server/src/nest/tours/tours.controller.ts b/server/src/nest/tours/tours.controller.ts index 7eed7f513..4900a704a 100644 --- a/server/src/nest/tours/tours.controller.ts +++ b/server/src/nest/tours/tours.controller.ts @@ -2,10 +2,12 @@ import { ADDON_IDS } from '../../addons'; import { AddonGuard } from '../addons/addon.guard'; import { RequireAddon } from '../addons/require-addon.decorator'; import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard'; import { TourCreateDto } from './dto/tour-create.dto'; import { ToursService } from './tours.service'; -import { Body, Controller, Get, Headers, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { tourDetailResponseSchema } from '@trek/shared'; /** * /api/trips/:tripId/tours: the Tours facet, read and route edits. @@ -50,4 +52,17 @@ export class ToursController { ) { return await this.tours.updateTour(tripId, placeId, body, socketId); } + + /** Takes an import planned over in place back to its recorded track; the planning is dropped. */ + @RequirePermission('place_edit') + @Post(':placeId/restore-recorded') + @HttpCode(200) + @ResponseContract(tourDetailResponseSchema) + async restoreRecorded( + @Param('tripId') tripId: string, + @Param('placeId') placeId: string, + @Headers('x-socket-id') socketId?: string, + ) { + return await this.tours.restoreRecordedTrack(tripId, placeId, socketId); + } } diff --git a/server/src/nest/tours/tours.module.ts b/server/src/nest/tours/tours.module.ts index 7842cd9a6..a71c8ca79 100644 --- a/server/src/nest/tours/tours.module.ts +++ b/server/src/nest/tours/tours.module.ts @@ -1,4 +1,5 @@ import { Places } from '../../db/entities/Places.entity'; +import { TourSources } from '../../db/entities/TourSources.entity'; import { TourTypes } from '../../db/entities/TourTypes.entity'; import { TourWaypoints } from '../../db/entities/TourWaypoints.entity'; import { Tours } from '../../db/entities/Tours.entity'; @@ -6,7 +7,10 @@ import { AddonsModule } from '../addons/addons.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { PlaceImportModule } from '../place-import/place-import.module'; import { PlacesModule } from '../places/places.module'; +import { WandererModule } from '../wanderer/wanderer.module'; import { ToursImportController } from './tours-import.controller'; +import { ToursWandererController } from './tours-wanderer.controller'; +import { ToursWandererService } from './tours-wanderer.service'; import { ToursController } from './tours.controller'; import { ToursService } from './tours.service'; import { MikroOrmModule } from '@mikro-orm/nestjs'; @@ -26,9 +30,11 @@ import { Module } from '@nestjs/common'; PermissionsModule, AddonsModule, - MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, Places]), + // The wanderer import reads trails through the caller's own connection. + WandererModule, + MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, TourSources, Places]), ], - controllers: [ToursController, ToursImportController], - providers: [ToursService], + controllers: [ToursController, ToursImportController, ToursWandererController], + providers: [ToursService, ToursWandererService], }) export class ToursModule {} diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index 96b0aaad7..0ea38bcf8 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -6,6 +6,7 @@ import type { PlacesRepository } from '../../db/repositories/Places.repository'; import type { TourTypesRepository } from '../../db/repositories/TourTypes.repository'; import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository'; import type { TourListRow, ToursRepository, TourUpdate } from '../../db/repositories/Tours.repository'; +import { DomainError } from '../common/domain-error'; import { toRowId } from '../common/row-id'; import { UnitOfWork } from '../database/unit-of-work'; import { PlaceImportService } from '../place-import/place-import.service'; @@ -22,6 +23,8 @@ import { type TourDetailResponse, type TourListItem, type TourWaypoint, + type WandererDifficulty, + wandererDifficultyToSac, } from '@trek/shared'; export interface ImportGpxAsTourResult { @@ -68,6 +71,8 @@ export class ToursService { wanderer_ref: r.wanderer_ref, match_confidence: r.match_confidence, max_hiking_difficulty: r.max_hiking_difficulty, + derived_from_place_id: r.derived_from_place_id, + has_recorded_track: Boolean(r.has_recorded_track), planned: Boolean(r.planned), caution: r.match_confidence !== null && r.match_confidence < LOW_CONFIDENCE_THRESHOLD, has_waypoints: Boolean(r.has_waypoints), @@ -175,8 +180,17 @@ export class ToursService { this.validatePlannedTimes(input); const start = input.route_geometry[0]; const fields = this.routeFields(input); + const derivedFrom = input.derived_from_place_id ?? null; const placeId = await this.uow.transactional(async () => { + // What it was planned from has to be an import (no waypoints of its own) of this same trip, + // read in the write so it cannot go meanwhile. + if (derivedFrom !== null) { + const source = await this.toursRepo.findInTrip(tid, derivedFrom); + if (!source || source.has_waypoints) { + throw new DomainError(400, 'derived_from_place_id is not an imported tour of this trip'); + } + } const id = await this.placesRepo.insertTourPlace({ trip_id: tid, name: input.name, @@ -191,6 +205,7 @@ export class ToursService { ...fields, planned_duration_minutes: fields.planned_duration_minutes ?? null, break_additional_minutes: fields.break_additional_minutes ?? null, + derived_from_place_id: derivedFrom, }); await this.waypointsRepo.insertForPlace(id, input.waypoints); return id; @@ -212,6 +227,68 @@ export class ToursService { return { tour: this.toItem(row), waypoints }; } + /** + * Takes an import that was planned over in place back to the track it was + * recorded with: its line, the measures read from that line and the grade + * its source gave, without the waypoints. The tour keeps its id, days and notes. + */ + async restoreRecordedTrack(tripId: string, placeId: string, socketId?: string): Promise { + const tid = toRowId(tripId); + const pid = toRowId(placeId); + if (tid === null || pid === null) throw new NotFoundException('Tour not found'); + + await this.uow.transactional(async () => { + const row = await this.toursRepo.findInTrip(tid, pid); + if (!row) throw new NotFoundException('Tour not found'); + const recorded = await this.toursRepo.findRecordedTrack(tid, pid); + if (recorded === null) { + throw new DomainError(409, 'This tour keeps no recorded track to go back to', { + code: 'no_recorded_track', + details: { code: 'no_recorded_track' }, + }); + } + const geometry = parseRouteGeometry(recorded); + const start = geometry[0]; + if (!start) + throw new DomainError(409, 'The recorded track of this tour is empty', { + code: 'no_recorded_track', + details: { code: 'no_recorded_track' }, + }); + const metrics = computeTourMetrics(geometry); + await this.placesRepo.updateTourRoute(pid, tid, { + name: row.name, + lat: start[0], + lng: start[1], + route_geometry: recorded, + }); + await this.toursRepo.updateInTrip(tid, pid, { + tour_type: row.tour_type, + distance: metrics.distanceKm, + elevation_gain: metrics.elevationGainM, + elevation_loss: metrics.elevationLossM, + duration: null, + match_confidence: metrics.hasElevation ? 1 : 0.3, + // The grade its source gave, on the one scale tours use; a GPX file gives none. + max_hiking_difficulty: row.wanderer_ref + ? wandererDifficultyToSac(row.difficulty as WandererDifficulty | null) + : null, + }); + await this.waypointsRepo.deleteForPlace(pid); + await this.toursRepo.clearRecordedTrack(pid); + }); + + const result = await this.getTour(tripId, placeId); + const place = await this.placesRepo.findWithTagsAndRatings(pid); + if (!place) throw new Error('Restored tour could not be loaded'); + this.broadcastToursChanged(tripId, [pid], socketId); + try { + this.places.broadcast(tripId, 'place:updated', { place }, socketId); + } catch { + this.logger.warn(`Committed Tour ${placeId}: place notification failed`); + } + return result; + } + /** Replace a saved tour's derived route and routing controls as one write. */ async updateTour( tripId: string, @@ -234,6 +311,8 @@ export class ToursService { // one, and a place deleted meanwhile answers 404 instead of failing a // waypoint insert on its foreign key. if (!(await this.toursRepo.findInTrip(tid, pid))) throw new NotFoundException('Tour not found'); + // An import planned over for the first time keeps the track it was recorded with. + await this.toursRepo.keepRecordedTrack(pid); const placeUpdated = await this.placesRepo.updateTourRoute(pid, tid, { name: input.name, lat: start[0], @@ -328,6 +407,8 @@ export class ToursService { wanderer_ref: null, match_confidence: matchConfidence, max_hiking_difficulty: null, + derived_from_place_id: null, + has_recorded_track: 0, planned: 0, has_waypoints: 0, }), diff --git a/server/src/nest/transit/transit.helpers.ts b/server/src/nest/transit/transit.helpers.ts index ee18b3bba..989972305 100644 --- a/server/src/nest/transit/transit.helpers.ts +++ b/server/src/nest/transit/transit.helpers.ts @@ -98,63 +98,4 @@ export function deriveTransitStats( }; } -/** - * Google-encoded polyline codec. - * - * The client carries its own decoder for drawing a journey on the map - * (client/src/components/Map/transitGeometry.ts). This pair exists for a - * different job the client never has: the Google backend receives a walk split - * across several navigation steps, each with its own polyline, and has to - * MERGE them into the single walk leg the rest of TREK expects — which needs an - * encoder to put the joined path back on the wire. - */ -export function decodePolyline(encoded: string, precision: number): [number, number][] { - const factor = 10 ** precision; - const points: [number, number][] = []; - let index = 0; - let lat = 0; - let lng = 0; - while (index < encoded.length) { - for (const axis of [0, 1] as const) { - let result = 0; - let shift = 0; - let byte = 0x20; - while (byte >= 0x20) { - if (index >= encoded.length) return points; - byte = (encoded.codePointAt(index++) ?? 0) - 63; - result |= (byte & 0x1f) << shift; - shift += 5; - } - const delta = result & 1 ? ~(result >> 1) : result >> 1; - if (axis === 0) lat += delta; - else lng += delta; - } - points.push([lat / factor, lng / factor]); - } - return points; -} - -function encodeSigned(value: number): string { - let remaining = value < 0 ? ~(value << 1) : value << 1; - let out = ''; - while (remaining >= 0x20) { - out += String.fromCharCode((0x20 | (remaining & 0x1f)) + 63); - remaining >>= 5; - } - return out + String.fromCharCode(remaining + 63); -} - -export function encodePolyline(points: [number, number][], precision: number): string { - const factor = 10 ** precision; - let out = ''; - let lastLat = 0; - let lastLng = 0; - for (const [lat, lng] of points) { - const scaledLat = Math.round(lat * factor); - const scaledLng = Math.round(lng * factor); - out += encodeSigned(scaledLat - lastLat) + encodeSigned(scaledLng - lastLng); - lastLat = scaledLat; - lastLng = scaledLng; - } - return out; -} +export { decodePolyline, encodePolyline } from '../../utils/polyline'; diff --git a/server/src/nest/trips/trips.service.ts b/server/src/nest/trips/trips.service.ts index 26ecb14f6..08c3ede6f 100644 --- a/server/src/nest/trips/trips.service.ts +++ b/server/src/nest/trips/trips.service.ts @@ -1,31 +1,46 @@ -import { Injectable } from '@nestjs/common'; -import path from 'path'; -import { EntityManager } from '@mikro-orm/core'; -import { Trips } from '../../db/entities/Trips.entity'; -import { Days } from '../../db/entities/Days.entity'; -import { JourneyEntries } from '../../db/entities/JourneyEntries.entity'; -import { Users } from '../../db/entities/Users.entity'; -import { Places } from '../../db/entities/Places.entity'; -import { DayAssignments } from '../../db/entities/DayAssignments.entity'; import { AssignmentParticipants } from '../../db/entities/AssignmentParticipants.entity'; -import { Tags } from '../../db/entities/Tags.entity'; -import { DayNotes } from '../../db/entities/DayNotes.entity'; -import { RoadtripVias } from '../../db/entities/RoadtripVias.entity'; -import { RoadtripDayTracks } from '../../db/entities/RoadtripDayTracks.entity'; -import { RoadtripPreferences } from '../../db/entities/RoadtripPreferences.entity'; -import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.entity'; -import { DayAccommodations } from '../../db/entities/DayAccommodations.entity'; -import { Reservations } from '../../db/entities/Reservations.entity'; -import { BudgetItems } from '../../db/entities/BudgetItems.entity'; +import { BudgetCategoryOrder } from '../../db/entities/BudgetCategoryOrder.entity'; import { BudgetItemMembers } from '../../db/entities/BudgetItemMembers.entity'; import { BudgetItemPayers } from '../../db/entities/BudgetItemPayers.entity'; -import { BudgetCategoryOrder } from '../../db/entities/BudgetCategoryOrder.entity'; +import { BudgetItems } from '../../db/entities/BudgetItems.entity'; +import { DayAccommodations } from '../../db/entities/DayAccommodations.entity'; +import { DayAssignments } from '../../db/entities/DayAssignments.entity'; +import { DayNotes } from '../../db/entities/DayNotes.entity'; +import { Days } from '../../db/entities/Days.entity'; +import { JourneyEntries } from '../../db/entities/JourneyEntries.entity'; import { PackingBags } from '../../db/entities/PackingBags.entity'; import { PackingItems } from '../../db/entities/PackingItems.entity'; +import { Places } from '../../db/entities/Places.entity'; +import { Reservations } from '../../db/entities/Reservations.entity'; +import { RoadtripDayBoundaries } from '../../db/entities/RoadtripDayBoundaries.entity'; +import { RoadtripDayTracks } from '../../db/entities/RoadtripDayTracks.entity'; +import { RoadtripPreferences } from '../../db/entities/RoadtripPreferences.entity'; +import { RoadtripVias } from '../../db/entities/RoadtripVias.entity'; +import { Tags } from '../../db/entities/Tags.entity'; import { TodoItems } from '../../db/entities/TodoItems.entity'; -import { Tours } from '../../db/entities/Tours.entity'; import { TourWaypoints } from '../../db/entities/TourWaypoints.entity'; +import { Tours } from '../../db/entities/Tours.entity'; +import { Trips } from '../../db/entities/Trips.entity'; +import { Users } from '../../db/entities/Users.entity'; import type { TourWaypointRow } from '../../db/repositories/TourWaypoints.repository'; +import type { Trip, User } from '../../types'; +import { BudgetService, type CurrencyRebase } from '../budget/budget.service'; +import { NotFoundError, ValidationError } from '../common/domain-errors'; +import { legacyBoundIntegerText } from '../common/row-id'; +import { appClock } from '../common/timezoneService'; +import { UnitOfWork } from '../database/unit-of-work'; +import { DaysService } from '../days/days.service'; +import { PermissionsService } from '../permissions/permissions.service'; +import { escapeLikePattern } from '../places/places.helpers'; +import { RealtimeService } from '../realtime/realtime.service'; +import { ReservationsService } from '../reservations/reservations.service'; +import { SettingsService } from '../settings/settings.service'; +import { StorageService } from '../storage/storage.service'; +import { UnsplashService } from '../unsplash/unsplash.service'; +import { VacayService } from '../vacay/vacay.service'; +import { assertTripSpan } from './trips.helpers'; +import { EntityManager } from '@mikro-orm/core'; +import { Injectable } from '@nestjs/common'; import { addIsoDays, planDayGrid, @@ -36,22 +51,8 @@ import { type TrekWsPayload, type TrekWsTripEventName, } from '@trek/shared'; -import { RealtimeService } from '../realtime/realtime.service'; -import { PermissionsService } from '../permissions/permissions.service'; -import type { Trip, User } from '../../types'; -import { DaysService } from '../days/days.service'; -import { BudgetService, type CurrencyRebase } from '../budget/budget.service'; -import { ReservationsService } from '../reservations/reservations.service'; -import { VacayService } from '../vacay/vacay.service'; -import { UnsplashService } from '../unsplash/unsplash.service'; -import { StorageService } from '../storage/storage.service'; -import { SettingsService } from '../settings/settings.service'; -import { escapeLikePattern } from '../places/places.helpers'; -import { NotFoundError, ValidationError } from '../common/domain-errors'; -import { UnitOfWork } from '../database/unit-of-work'; -import { legacyBoundIntegerText } from '../common/row-id'; -import { appClock } from '../common/timezoneService'; -import { assertTripSpan } from './trips.helpers'; + +import path from 'path'; interface CreateTripData { title: string; @@ -99,7 +100,14 @@ export interface DeleteTripInfo { } export interface AddMemberResult { - member: { id: number; username: string; email: string; avatar?: string | null; role: string; avatar_url: string | null }; + member: { + id: number; + username: string; + email: string; + avatar?: string | null; + role: string; + avatar_url: string | null; + }; targetUserId: number; tripTitle: string; } @@ -321,7 +329,12 @@ export class TripsService { return this.permissions.checkPermission(action, role, ownerId, userId, isMember); } - broadcast(tripId: string, event: E, payload: TrekWsPayload, socketId: string | undefined): void { + broadcast( + tripId: string, + event: E, + payload: TrekWsPayload, + socketId: string | undefined, + ): void { this.realtime.broadcast(tripId, event, payload, socketId); } @@ -341,28 +354,40 @@ export class TripsService { * runs in one `uow.transactional` — a savepoint when `updateTrip`'s own * block already holds the transaction, its own transaction from `create`. */ - async generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, dayCount?: number): Promise { + async generateDays( + tripId: number | bigint | string, + startDate: string | null, + endDate: string | null, + dayCount?: number, + ): Promise { const trip_id = Number(tripId); return await this.uow.transactional(async () => { const existing = await this.daysRepo.listForDayGrid(trip_id); // TP77 const stays = await this.daysRepo.listDayGridStays(trip_id); // TP78 const plan = planDayGrid({ - days: existing.map(d => ({ id: d.id, day_number: d.day_number, date: d.date, hasPlanItems: !!d.has_plan_items })), + days: existing.map((d) => ({ + id: d.id, + day_number: d.day_number, + date: d.date, + hasPlanItems: !!d.has_plan_items, + })), stays, startDate, endDate, dayCount, }); - const dateBefore = new Map(existing.map(d => [d.id, d.date])); + const dateBefore = new Map(existing.map((d) => [d.id, d.date])); for (let i = 0; i < existing.length; i++) await this.daysRepo.setDayNumber(existing[i].id, -(i + 1)); // TP2 for (let i = 0; i < plan.rows.length; i++) { const row = plan.rows[i]; - if (row.id === null) await this.daysRepo.insertDay({ trip_id, day_number: i + 1, date: row.date }); // TP10 + if (row.id === null) + await this.daysRepo.insertDay({ trip_id, day_number: i + 1, date: row.date }); // TP10 // A row that had no date and gets none is only renumbered, so an odd // stored value is left as it was, the way the rebuild always treated it. - else if (row.date === null && !dateBefore.get(row.id)) await this.daysRepo.setDayNumber(row.id, i + 1); // TP2 + else if (row.date === null && !dateBefore.get(row.id)) + await this.daysRepo.setDayNumber(row.id, i + 1); // TP2 else await this.daysRepo.setDayNumberAndDate(row.id, i + 1, row.date); // TP9 } for (const gone of plan.removed) await this.daysRepo.deleteById(gone.id); // TP11 @@ -385,15 +410,19 @@ export class TripsService { if (startDate && !endDate) endDate = addIsoDays(startDate, 6); else if (!startDate && endDate) startDate = addIsoDays(endDate, -6); if (startDate && endDate) assertTripSpan(startDate, endDate); - const rd = data.reminder_days !== undefined - ? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3) - : 3; + const rd = + data.reminder_days !== undefined + ? Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 + ? Number(data.reminder_days) + : 3 + : 3; const currency = data.currency || (await this.defaultCurrencyFor(userId)); // The trip and its days commit together: a failure in between used to leave // a dated trip without a single day. const tripId = await this.uow.transactional(async () => { - const id = await this.tripsRepo.insertTrip({ // TP18 + const id = await this.tripsRepo.insertTrip({ + // TP18 user_id: userId, title: data.title, description: data.description || null, @@ -412,7 +441,7 @@ export class TripsService { /** TP20 — `TripsRepository.findForViewer`: `TRIP_SELECT` scoped to one trip AND the access predicate. */ async get(tripId: string | number, userId: number) { - return await this.tripsRepo.findForViewer(tripId, userId) as Trip | undefined; + return (await this.tripsRepo.findForViewer(tripId, userId)) as Trip | undefined; } /** @@ -428,7 +457,7 @@ export class TripsService { */ /** TP21 — `TripsRepository.activeTrip`: the triple `CASE WHEN … relevance` projection and the double-`CASE WHEN` `ORDER BY`. */ async activeTrip(userId: number, today = appClock().date) { - return await this.tripsRepo.activeTrip(userId, today) as ActiveTrip & { relevance: number } | undefined; + return (await this.tripsRepo.activeTrip(userId, today)) as (ActiveTrip & { relevance: number }) | undefined; } /** @@ -472,8 +501,14 @@ export class TripsService { * rebase, the trip row (TP25), the leave entries that follow its dates and the rebuilt day grid * are one transaction, so a failing `generateDays` or booking resync leaves all of them as they were. */ - async updateTrip(tripId: string | number, userId: number, data: UpdateTripData, userRole: string, rebase: CurrencyRebase | null = null): Promise { - const trip = await this.tripsRepo.findRaw(tripId) as (Trip & { reminder_days?: number }) | null; // TP24 + async updateTrip( + tripId: string | number, + userId: number, + data: UpdateTripData, + userRole: string, + rebase: CurrencyRebase | null = null, + ): Promise { + const trip = (await this.tripsRepo.findRaw(tripId)) as (Trip & { reminder_days?: number }) | null; // TP24 if (!trip) throw new NotFoundError('Trip not found'); const { title, description, currency, is_archived, cover_image, reminder_days } = data; @@ -485,9 +520,12 @@ export class TripsService { const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived; const newCover = cover_image !== undefined ? cover_image : trip.cover_image; const oldReminder = (trip as any).reminder_days ?? 3; - const newReminder = reminder_days !== undefined - ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : oldReminder) - : oldReminder; + const newReminder = + reminder_days !== undefined + ? Number(reminder_days) >= 0 && Number(reminder_days) <= 30 + ? Number(reminder_days) + : oldReminder + : oldReminder; const tripIdNum = Number(tripId); // safe: `trip` above only resolved through the raw-bind seam on a real row (Task 6 review's own ruling on this exact conversion) // The row, the leave entries that follow its dates and the rebuilt day grid are @@ -497,7 +535,8 @@ export class TripsService { await this.uow.transactional(async () => { // Before the row: the rebase reads the outgoing currency off it (#1543). if (rebase) await this.budget.applyCurrencyRebase(tripId, rebase); - await this.tripsRepo.updateTripRow(tripIdNum, { // TP25 + await this.tripsRepo.updateTripRow(tripIdNum, { + // TP25 title: newTitle, description: newDesc ?? null, start_date: newStart || null, @@ -514,19 +553,24 @@ export class TripsService { }); if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date) - vacayId = await this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart); + vacayId = await this.vacay.shiftOwnerEntriesForTripWindow( + trip.user_id, + trip.start_date, + trip.end_date, + newStart, + ); if (regenerate) { // Accommodations have no absolute date columns, so their pre-change dates must be // snapshotted before generateDays re-dates the day rows in place. const prevDays = await this.daysRepo.listOrderedForReorder(tripIdNum); // TP26 - const prevDateByDayId = new Map(prevDays.map(d => [d.id, d.date])); + const prevDateByDayId = new Map(prevDays.map((d) => [d.id, d.date])); removedDays = (await this.generateDays(tripId, newStart || null, newEnd || null, dayCount)).removed; if (data.date_shift_mode === 'shift_all') { // Explicit "shift everything": bookings stay glued to their (re-dated) day rows, // so re-stamp reservation_time to follow — same rules as reorderDays/insertDay. const newDays = await this.daysRepo.listOrderedForReorder(tripIdNum); // TP27 - const newDateByDayId = new Map(newDays.map(d => [d.id, d.date])); + const newDateByDayId = new Map(newDays.map((d) => [d.id, d.date])); await this.days.restampReservationDates(tripIdNum, prevDateByDayId, newDateByDayId); } else { // Default: generateDays re-dates day rows positionally; re-anchor dated bookings to @@ -661,7 +705,8 @@ export class TripsService { const newTitle = title || src.title; return await this.uow.transactional(async () => { - const newTripId = await this.tripsRepo.insertTripCopy({ // TP37 + const newTripId = await this.tripsRepo.insertTripCopy({ + // TP37 user_id: newOwnerId, title: newTitle, description: src.description, @@ -675,8 +720,13 @@ export class TripsService { const oldDays = await this.daysRepo.listByTrip(Number(sourceTripId)); // TP38 const dayMap = new Map(); for (const d of oldDays) { - const newDayId = await this.daysRepo.insertDayCopy({ // TP39 - trip_id: newTripId, day_number: d.day_number, date: d.date, notes: d.notes, title: d.title, + const newDayId = await this.daysRepo.insertDayCopy({ + // TP39 + trip_id: newTripId, + day_number: d.day_number, + date: d.date, + notes: d.notes, + title: d.title, }); dayMap.set(d.id, newDayId); } @@ -684,15 +734,35 @@ export class TripsService { const oldPlaces = await this.placesRepo.listAllForTrip(sourceTripId); // TP40 const placeMap = new Map(); for (const p of oldPlaces) { - const newPlaceId = await this.placesRepo.insertPlaceCopy({ // TP41 - trip_id: newTripId, name: p.name, description: p.description, lat: p.lat, lng: p.lng, - address: p.address, category_id: p.category_id, price: p.price, currency: p.currency, - reservation_status: p.reservation_status, reservation_notes: p.reservation_notes, - reservation_datetime: p.reservation_datetime, place_time: p.place_time, end_time: p.end_time, - duration_minutes: p.duration_minutes, notes: p.notes, image_url: p.image_url, - google_place_id: p.google_place_id, google_ftid: p.google_ftid, website: p.website, phone: p.phone, - transport_mode: p.transport_mode, osm_id: p.osm_id, amap_poi_id: p.amap_poi_id, - route_geometry: p.route_geometry, route_color: p.route_color, stop_type: p.stop_type, + const newPlaceId = await this.placesRepo.insertPlaceCopy({ + // TP41 + trip_id: newTripId, + name: p.name, + description: p.description, + lat: p.lat, + lng: p.lng, + address: p.address, + category_id: p.category_id, + price: p.price, + currency: p.currency, + reservation_status: p.reservation_status, + reservation_notes: p.reservation_notes, + reservation_datetime: p.reservation_datetime, + place_time: p.place_time, + end_time: p.end_time, + duration_minutes: p.duration_minutes, + notes: p.notes, + image_url: p.image_url, + google_place_id: p.google_place_id, + google_ftid: p.google_ftid, + website: p.website, + phone: p.phone, + transport_mode: p.transport_mode, + osm_id: p.osm_id, + amap_poi_id: p.amap_poi_id, + route_geometry: p.route_geometry, + route_color: p.route_color, + stop_type: p.stop_type, fill_percent: p.fill_percent, }); placeMap.set(p.id, newPlaceId); @@ -708,7 +778,13 @@ export class TripsService { for (const v of oldVias) { const newDayId = dayMap.get(v.day_id); if (newDayId) { - await this.roadtripViasRepo.insertVia({ day_id: newDayId, after_order_index: v.after_order_index, sequence: v.sequence, lat: v.lat, lng: v.lng }); // TP43 + await this.roadtripViasRepo.insertVia({ + day_id: newDayId, + after_order_index: v.after_order_index, + sequence: v.sequence, + lat: v.lat, + lng: v.lng, + }); // TP43 } } @@ -743,7 +819,10 @@ export class TripsService { for (const { place_id, ...tour } of oldTours) { const newPlaceId = placeMap.get(place_id); if (!newPlaceId) continue; - await this.toursRepo.insertCopy(newPlaceId, tour); + // A tour planned from an import points at the copied import, never back into the source trip. + const derivedFrom = + tour.derived_from_place_id === null ? null : (placeMap.get(tour.derived_from_place_id) ?? null); + await this.toursRepo.insertCopy(newPlaceId, { ...tour, derived_from_place_id: derivedFrom }); copiedTours.add(place_id); } @@ -764,11 +843,18 @@ export class TripsService { const newDayId = dayMap.get(a.day_id); const newPlaceId = placeMap.get(a.place_id); if (newDayId && newPlaceId) { - const newAssignmentId = await this.dayAssignmentsRepo.insertAssignmentCopy({ // TP49 - day_id: newDayId, place_id: newPlaceId, order_index: a.order_index, notes: a.notes, - reservation_status: a.reservation_status, reservation_notes: a.reservation_notes, - reservation_datetime: a.reservation_datetime, assignment_time: a.assignment_time, - assignment_end_time: a.assignment_end_time, end_day: a.end_day ?? 0, + const newAssignmentId = await this.dayAssignmentsRepo.insertAssignmentCopy({ + // TP49 + day_id: newDayId, + place_id: newPlaceId, + order_index: a.order_index, + notes: a.notes, + reservation_status: a.reservation_status, + reservation_notes: a.reservation_notes, + reservation_datetime: a.reservation_datetime, + assignment_time: a.assignment_time, + assignment_end_time: a.assignment_end_time, + end_day: a.end_day ?? 0, }); assignmentMap.set(a.id, newAssignmentId); } @@ -787,7 +873,12 @@ export class TripsService { // new trip's `roadtrip_day_boundaries` starts empty, same reasoning // as the via/track upserts above. if (from && to !== undefined) { - await this.roadtripDayBoundariesRepo.upsertBoundary(newTripId, { day_number: boundary.day_number, from_assignment_id: from, to_assignment_id: to, fraction: boundary.fraction }); // TP52 + await this.roadtripDayBoundariesRepo.upsertBoundary(newTripId, { + day_number: boundary.day_number, + from_assignment_id: from, + to_assignment_id: to, + fraction: boundary.fraction, + }); // TP52 } } @@ -805,9 +896,17 @@ export class TripsService { const newStartDay = dayMap.get(a.start_day_id); const newEndDay = dayMap.get(a.end_day_id); if (newPlaceId && newStartDay && newEndDay) { - const newAccomId = await this.dayAccommodationsRepo.insertStay({ // TP56 - trip_id: newTripId, place_id: newPlaceId, start_day_id: newStartDay, end_day_id: newEndDay, - check_in: a.check_in, check_in_end: a.check_in_end, check_out: a.check_out, confirmation: a.confirmation, notes: a.notes, + const newAccomId = await this.dayAccommodationsRepo.insertStay({ + // TP56 + trip_id: newTripId, + place_id: newPlaceId, + start_day_id: newStartDay, + end_day_id: newEndDay, + check_in: a.check_in, + check_in_end: a.check_in_end, + check_out: a.check_out, + confirmation: a.confirmation, + notes: a.notes, }); accomMap.set(a.id, newAccomId); } @@ -844,12 +943,22 @@ export class TripsService { // the NEW value is re-formatted to the legacy raw-bound `'.0'` // TEXT shape on the way back out — see `legacyBoundIntegerText`. accommodation_id: newAccomId != null ? legacyBoundIntegerText(newAccomId) : null, - title: r.title, reservation_time: r.reservation_time, reservation_end_time: r.reservation_end_time, - location: r.location, confirmation_number: r.confirmation_number, notes: r.notes, url: r.url, status: r.status, type: r.type, + title: r.title, + reservation_time: r.reservation_time, + reservation_end_time: r.reservation_end_time, + location: r.location, + confirmation_number: r.confirmation_number, + notes: r.notes, + url: r.url, + status: r.status, + type: r.type, // ingest_state travels with the copy: a staged booking must not turn // 'live' just because the trip was duplicated, or it lands in the // duplicate's public feed. - metadata: r.metadata, day_plan_position: r.day_plan_position, needs_review: r.needs_review ?? 0, ingest_state: r.ingest_state ?? 'live', + metadata: r.metadata, + day_plan_position: r.day_plan_position, + needs_review: r.needs_review ?? 0, + ingest_state: r.ingest_state ?? 'live', }); // TP59 reservationMap.set(r.id, newReservationId); } @@ -867,7 +976,7 @@ export class TripsService { for (const row of [...oldBudgetMembers, ...oldBudgetPayers]) { if (row.user_id !== newOwnerId) sharedWithOthers.add(row.budget_item_id); } - const hasSplitRows = new Set(oldBudgetMembers.map(bm => bm.budget_item_id)); + const hasSplitRows = new Set(oldBudgetMembers.map((bm) => bm.budget_item_id)); // TP60 — Plan 3e Task 2, converted. const oldBudget = await this.budgetItemsRepo.listAllForTrip(sourceTripId); @@ -878,11 +987,18 @@ export class TripsService { // split is its member count and goes with it; a typed one stays. A receipt // split names who had which line, so it goes as well. const newItemId = await this.budgetItemsRepo.insertCopy({ - trip_id: newTripId, category: b.category, name: b.name, total_price: b.total_price, - persons: planningOnly && hasSplitRows.has(b.id) ? null : b.persons, days: b.days, - note: b.note, sort_order: b.sort_order, + trip_id: newTripId, + category: b.category, + name: b.name, + total_price: b.total_price, + persons: planningOnly && hasSplitRows.has(b.id) ? null : b.persons, + days: b.days, + note: b.note, + sort_order: b.sort_order, reservation_id: b.reservation_id ? (reservationMap.get(b.reservation_id) ?? null) : null, - currency: b.currency, exchange_rate: b.exchange_rate ?? 1, expense_date: b.expense_date, + currency: b.currency, + exchange_rate: b.exchange_rate ?? 1, + expense_date: b.expense_date, ticket_json: planningOnly ? null : b.ticket_json, paid_by_user_id: b.paid_by_user_id === newOwnerId ? b.paid_by_user_id : null, }); @@ -893,14 +1009,25 @@ export class TripsService { if (sharedWithOthers.has(bm.budget_item_id)) continue; const newItemId = budgetMap.get(bm.budget_item_id); // TP63 — Plan 3e Task 2, converted. - if (newItemId) await this.budgetItemMembersRepo.insertIgnore({ budget_item_id: newItemId, user_id: bm.user_id, paid: bm.paid ?? 0, amount: bm.amount }); + if (newItemId) + await this.budgetItemMembersRepo.insertIgnore({ + budget_item_id: newItemId, + user_id: bm.user_id, + paid: bm.paid ?? 0, + amount: bm.amount, + }); } for (const bp of oldBudgetPayers) { if (sharedWithOthers.has(bp.budget_item_id)) continue; const newItemId = budgetMap.get(bp.budget_item_id); // TP65 — Plan 3e Task 2, converted. - if (newItemId) await this.budgetItemPayersRepo.insertIgnore({ budget_item_id: newItemId, user_id: bp.user_id, amount: bp.amount ?? 0 }); + if (newItemId) + await this.budgetItemPayersRepo.insertIgnore({ + budget_item_id: newItemId, + user_id: bp.user_id, + amount: bp.amount ?? 0, + }); } // TP66 — Plan 3e Task 3, converted. @@ -909,7 +1036,11 @@ export class TripsService { for (const bag of oldBags) { // TP67 — Plan 3e Task 3, converted (`insertBag`, PK42's own column set). const newBagId = await this.packingBagsRepo.insertBag({ - trip_id: newTripId, name: bag.name, color: bag.color, sort_order: bag.sort_order, weight_limit_grams: bag.weight_limit_grams, + trip_id: newTripId, + name: bag.name, + color: bag.color, + sort_order: bag.sort_order, + weight_limit_grams: bag.weight_limit_grams, }); bagMap.set(bag.id, newBagId); } @@ -926,9 +1057,14 @@ export class TripsService { const isPrivate = p.is_private ? 1 : 0; // TP69 — Plan 3e Task 3, converted. await this.packingItemsRepo.insertCopy({ - trip_id: newTripId, name: p.name, category: p.category, sort_order: p.sort_order, weight_grams: p.weight_grams, + trip_id: newTripId, + name: p.name, + category: p.category, + sort_order: p.sort_order, + weight_grams: p.weight_grams, bag_id: p.bag_id ? (bagMap.get(p.bag_id) ?? null) : null, - is_private: isPrivate, owner_id: isPrivate ? newOwnerId : null, + is_private: isPrivate, + owner_id: isPrivate ? newOwnerId : null, }); } @@ -936,8 +1072,14 @@ export class TripsService { for (const n of oldNotes) { const newDayId = dayMap.get(n.day_id); if (newDayId) { - await this.dayNotesRepo.insertNoteCopy({ // TP71 - day_id: newDayId, trip_id: newTripId, text: n.text, time: n.time, icon: n.icon, sort_order: n.sort_order, + await this.dayNotesRepo.insertNoteCopy({ + // TP71 + day_id: newDayId, + trip_id: newTripId, + text: n.text, + time: n.time, + icon: n.icon, + sort_order: n.sort_order, }); } } @@ -947,8 +1089,13 @@ export class TripsService { for (const t of oldTodos) { // TP73 — Plan 3e Task 4, converted (`assigned_user_id` deliberately not carried over). await this.todoItemsRepo.insertCopy({ - trip_id: newTripId, name: t.name, category: t.category, sort_order: t.sort_order, - due_date: t.due_date, description: t.description, priority: t.priority, + trip_id: newTripId, + name: t.name, + category: t.category, + sort_order: t.sort_order, + due_date: t.due_date, + description: t.description, + priority: t.priority, }); } @@ -967,7 +1114,6 @@ export class TripsService { async getCopiedTrip(newTripId: number, userId: number) { return await this.tripsRepo.findForViewer(newTripId, userId); } - } // Defined in common/ so calendar and maps can raise them without importing the diff --git a/server/src/nest/wanderer/wanderer-enabled.guard.ts b/server/src/nest/wanderer/wanderer-enabled.guard.ts new file mode 100644 index 000000000..5de4c1f92 --- /dev/null +++ b/server/src/nest/wanderer/wanderer-enabled.guard.ts @@ -0,0 +1,23 @@ +import { AddonsService } from '../addons/addons.service'; +import { DomainError } from '../common/domain-error'; +import { CanActivate, Injectable } from '@nestjs/common'; + +/** + * Lets a wanderer route through only while the admin allows wanderer under + * Tours (Admin, Addons, Tours). Runs after the Tours addon check, so with + * Tours off the caller still hears that Tours is off. + */ +@Injectable() +export class WandererEnabledGuard implements CanActivate { + constructor(private readonly addons: AddonsService) {} + + async canActivate(): Promise { + if (!(await this.addons.isWandererAvailable())) { + throw new DomainError(404, 'wanderer is not enabled', { + code: 'wanderer_disabled', + details: { code: 'disabled' }, + }); + } + return true; + } +} diff --git a/server/src/nest/wanderer/wanderer.client.ts b/server/src/nest/wanderer/wanderer.client.ts new file mode 100644 index 000000000..f29fb7684 --- /dev/null +++ b/server/src/nest/wanderer/wanderer.client.ts @@ -0,0 +1,325 @@ +import { readCappedJson } from '../../utils/cappedFetch'; +import { ResponseTooLargeError, safeFetch, SsrfBlockedError } from '../../utils/ssrfGuard'; +import { Injectable } from '@nestjs/common'; +import { WANDERER_SEARCH_PAGE_SIZE, type WandererBbox, type WandererErrorCode } from '@trek/shared'; + +import { z } from 'zod'; + +/** + * The only place that talks to a user's wanderer instance. + * + * Verified against wanderer v0.21.0, its source and a running instance rather + * than its prose docs: + * + * - **Auth** is `Authorization: Bearer wanderer_key_...` on `/api/*`. The web + * hook only exchanges a token that starts with `wanderer_key`; anything else + * is quietly served as an anonymous visitor, and a revoked token answers + * HTTP 500 "Failed to verify API token ... Invalid or revoked API token". + * - So **HTTP 200 proves nothing**. The test lists the caller's own API tokens: + * authenticated, that list holds at least the token being used; anonymous, + * it is empty. + * - **Search** is `POST /api/v1/search/trails` (Meilisearch through the web + * app, scoped to what the caller may see). Its documents carry the trail's + * extent (`min_lat` ... `max_lon`), which is what the map search filters on, + * and a simplified line as an encoded polyline (precision 5). + * - The **original GPX** is a PocketBase file: `GET /api/v1/files/trails/{id}/{gpx}`. + */ + +const TIMEOUT_MS = 15_000; +/** A page of twenty search hits with their lines is a few dozen kilobytes. */ +const MAX_JSON_BYTES = 2 * 1024 * 1024; +/** The same ceiling the GPX upload has. */ +const MAX_GPX_BYTES = 10 * 1024 * 1024; + +export interface WandererCreds { + /** Instance origin, without /api. */ + baseUrl: string; + token: string; + allowInsecureTls: boolean; +} + +/** A failed call, its reason as a code the client translates; `detail` stays in the log. */ +export class WandererError extends Error { + constructor( + readonly code: WandererErrorCode, + message: string, + readonly detail?: string, + ) { + super(message); + this.name = 'WandererError'; + } +} + +const tokenListSchema = z.object({ + items: z.array(z.object({ user: z.string().nullish() })), + totalItems: z.number().optional(), +}); + +/** A user record with the ActivityPub actors that stand for it; the local one authors the user's trails. */ +const userActorsSchema = z.object({ + expand: z + .object({ + activitypub_actors_via_user: z.array(z.object({ id: z.string(), is_local: z.boolean().nullish() })).nullish(), + }) + .nullish(), +}); + +const searchHitSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullish(), + location: z.string().nullish(), + distance: z.number().nullish(), + duration: z.number().nullish(), + elevation_gain: z.number().nullish(), + elevation_loss: z.number().nullish(), + difficulty: z.number().nullish(), + public: z.boolean().nullish(), + author_name: z.string().nullish(), + domain: z.string().nullish(), + polyline: z.string().nullish(), +}); +export type WandererSearchHit = z.infer; + +const searchResultSchema = z.object({ + hits: z.array(searchHitSchema), + estimatedTotalHits: z.number().nullish(), + totalHits: z.number().nullish(), +}); + +const trailSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullish(), + gpx: z.string().nullish(), + updated: z.string().nullish(), + public: z.boolean().nullish(), + author: z.string().nullish(), +}); +export type WandererTrailRecord = z.infer; + +/** + * `https://host/sub` from what people paste: a trailing slash, `/api` or the + * full `/api/v1` are all taken off, so the client can append its own. + */ +export function instanceOrigin(baseUrl: string): string { + return baseUrl + .trim() + .replace(/\/+$/, '') + .replace(/\/api\/v1$/i, '') + .replace(/\/api$/i, ''); +} + +const TLS_CODES = new Set([ + 'CERT_HAS_EXPIRED', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'ERR_TLS_CERT_ALTNAME_INVALID', +]); + +function networkFailure(err: unknown): WandererError { + if (err instanceof SsrfBlockedError) + return new WandererError('blocked_address', 'TREK may not reach that wanderer address', err.message); + const cause = (err as { cause?: { code?: string } })?.cause; + if (cause?.code && TLS_CODES.has(cause.code)) { + return new WandererError('tls', 'The wanderer certificate could not be verified', cause.code); + } + if (err instanceof ResponseTooLargeError) return new WandererError('too_large', 'wanderer sent more than expected'); + return new WandererError('unreachable', 'Could not reach wanderer', err instanceof Error ? err.message : undefined); +} + +async function failureOf(response: Response): Promise { + const text = await response.text().catch(() => ''); + if (response.status === 401 || /api token/i.test(text)) { + return new WandererError('unauthorized', 'wanderer did not accept the API token', `HTTP ${response.status}`); + } + if (response.status === 403) return new WandererError('forbidden', 'wanderer refused access', 'HTTP 403'); + if (response.status === 404) return new WandererError('not_found', 'wanderer has no such trail', 'HTTP 404'); + return new WandererError('server_error', `wanderer answered HTTP ${response.status}`, text.slice(0, 200)); +} + +/** PocketBase record ids are short lowercase alphanumerics; anything else never reaches a filter or a path. */ +export function isTrailId(value: string): boolean { + return /^[a-z0-9]{1,32}$/.test(value); +} + +const DIFFICULTIES = ['easy', 'moderate', 'difficult'] as const; + +/** wanderer indexes difficulty as 0, 1, 2. */ +export function difficultyOf(value: number | null | undefined): (typeof DIFFICULTIES)[number] | null { + return value != null && value >= 0 && value < DIFFICULTIES.length ? DIFFICULTIES[value] : null; +} + +/** The Meilisearch filter for trails by these authors; the ids are checked record ids, so they quote safely. */ +function authorFilter(authors: readonly string[]): string { + return `author IN [${authors + .filter(isTrailId) + .map((id) => `'${id}'`) + .join(', ')}]`; +} + +/** The Meilisearch filter for trails whose extent overlaps the box; a box over the antimeridian takes either side. */ +export function bboxFilter(box: WandererBbox): string { + const lon = + box.west > box.east + ? `(max_lon >= ${box.west} OR min_lon <= ${box.east})` + : `max_lon >= ${box.west} AND min_lon <= ${box.east}`; + return `max_lat >= ${box.south} AND min_lat <= ${box.north} AND ${lon}`; +} + +@Injectable() +export class WandererClient { + private async call( + creds: WandererCreds, + path: string, + init: RequestInit = {}, + maxBytes = MAX_JSON_BYTES, + ): Promise { + let response: Response; + try { + response = await safeFetch( + `${instanceOrigin(creds.baseUrl)}${path}`, + { + ...init, + headers: { ...(init.headers as Record), Authorization: `Bearer ${creds.token}` }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }, + { rejectUnauthorized: !creds.allowInsecureTls, maxBytes }, + ); + } catch (err: unknown) { + throw networkFailure(err); + } + if (!response.ok) throw await failureOf(response); + return response; + } + + private async json(creds: WandererCreds, path: string, schema: z.ZodType, init?: RequestInit): Promise { + const response = await this.call(creds, path, { + ...init, + headers: { Accept: 'application/json', ...(init?.headers as Record) }, + }); + let body: unknown; + try { + body = await readCappedJson(response, MAX_JSON_BYTES); + } catch (err: unknown) { + // An answer that is not JSON at all (a proxy's login page, say) is not a network failure. + if (err instanceof SyntaxError) { + throw new WandererError( + 'invalid_response', + 'wanderer answered with something TREK does not understand', + err.message, + ); + } + throw networkFailure(err); + } + const parsed = schema.safeParse(body); + if (!parsed.success) { + throw new WandererError( + 'invalid_response', + 'wanderer answered with something TREK does not understand', + parsed.error.message.slice(0, 200), + ); + } + return parsed.data; + } + + /** + * Proves the token is accepted, and says how many trails it can see. The + * token list is the proof: see the class comment. + */ + async check(creds: WandererCreds): Promise<{ trailCount: number | null }> { + const authors = await this.ownActors(creds); + const count = await this.json(creds, '/api/v1/search/trails', searchResultSchema, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ q: '', options: { limit: 0, filter: [authorFilter(authors)] } }), + }).catch(() => null); + return { trailCount: count?.estimatedTotalHits ?? count?.totalHits ?? null }; + } + + /** + * The actors that author the token owner's trails. Only those trails are + * offered: the index also lists other people's public trails, and copying + * someone else's trail into a trip is not what a personal connection is for. + * The token list names its owner (an anonymous call lists none, see the + * class comment), and the owner's user record names its actors. + */ + async ownActors(creds: WandererCreds): Promise { + const tokens = await this.json(creds, '/api/v1/api-token?perPage=1', tokenListSchema); + const userId = tokens.items[0]?.user; + if (!userId) throw new WandererError('unauthorized', 'wanderer did not accept the API token', 'anonymous'); + if (!isTrailId(userId)) throw new WandererError('invalid_response', 'wanderer named its user oddly'); + const user = await this.json( + creds, + `/api/v1/user/${encodeURIComponent(userId)}?expand=activitypub_actors_via_user`, + userActorsSchema, + ); + const actors = (user.expand?.activitypub_actors_via_user ?? []) + .filter((actor) => actor.is_local !== false && isTrailId(actor.id)) + .map((actor) => actor.id); + if (actors.length === 0) throw new WandererError('invalid_response', 'wanderer named no author for the token'); + return actors; + } + + async search( + creds: WandererCreds, + query: { q: string; page: number; bbox?: WandererBbox }, + authors: readonly string[], + ): Promise<{ hits: WandererSearchHit[]; total: number }> { + const result = await this.json(creds, '/api/v1/search/trails', searchResultSchema, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + q: query.q, + options: { + limit: WANDERER_SEARCH_PAGE_SIZE, + offset: (query.page - 1) * WANDERER_SEARCH_PAGE_SIZE, + filter: [authorFilter(authors), ...(query.bbox ? [bboxFilter(query.bbox)] : [])], + attributesToRetrieve: Object.keys(searchHitSchema.shape), + }, + }), + }); + return { hits: result.hits, total: result.estimatedTotalHits ?? result.totalHits ?? result.hits.length }; + } + + /** One of the owner's trails as the search shows it, or null when it is not theirs or the token cannot see it. */ + async searchById(creds: WandererCreds, id: string, authors: readonly string[]): Promise { + if (!isTrailId(id)) return null; + const result = await this.json(creds, '/api/v1/search/trails', searchResultSchema, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + q: '', + options: { + limit: 1, + filter: [`id = '${id}'`, authorFilter(authors)], + attributesToRetrieve: Object.keys(searchHitSchema.shape), + }, + }), + }); + return result.hits[0] ?? null; + } + + async trail(creds: WandererCreds, id: string): Promise { + if (!isTrailId(id)) throw new WandererError('not_found', 'wanderer has no such trail'); + return await this.json(creds, `/api/v1/trail/${encodeURIComponent(id)}`, trailSchema); + } + + /** The trail's original GPX file, as text. */ + async gpx(creds: WandererCreds, trail: WandererTrailRecord): Promise { + if (!trail.gpx) throw new WandererError('not_found', 'The trail has no GPX file'); + const response = await this.call( + creds, + `/api/v1/files/trails/${encodeURIComponent(trail.id)}/${encodeURIComponent(trail.gpx)}`, + {}, + MAX_GPX_BYTES, + ); + try { + return await response.text(); + } catch (err: unknown) { + throw networkFailure(err); + } + } +} diff --git a/server/src/nest/wanderer/wanderer.controller.ts b/server/src/nest/wanderer/wanderer.controller.ts new file mode 100644 index 000000000..9ce5ee0d1 --- /dev/null +++ b/server/src/nest/wanderer/wanderer.controller.ts @@ -0,0 +1,60 @@ +import { ADDON_IDS } from '../../addons'; +import type { User } from '../../types'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { getClientIp } from '../audit/client-ip'; +import { CurrentUser } from '../auth-core/current-user.decorator'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; +import { WandererEnabledGuard } from './wanderer-enabled.guard'; +import { WandererSettingsDto, WandererTestDto } from './wanderer.dto'; +import { WandererService } from './wanderer.service'; +import { Body, Controller, Delete, Get, HttpCode, Post, Put, Req, UseGuards } from '@nestjs/common'; +import { + successResponseSchema, + wandererConnectionSchema, + wandererSaveResponseSchema, + wandererTestResponseSchema, +} from '@trek/shared'; + +import type { Request } from 'express'; + +/** + * `/api/integrations/wanderer`: the caller's own wanderer connection. It belongs + * to the Tours addon, which is what it feeds, and only ever touches the + * caller's own row. The test answers 200 with the reason in the body, since a + * form showing someone their own typo needs a field to render, not an error. + */ +@Controller('api/integrations/wanderer') +@UseGuards(AddonGuard, JwtAuthGuard, WandererEnabledGuard) +@RequireAddon(ADDON_IDS.TOURS, 'Tours') +export class WandererController { + constructor(private readonly wanderer: WandererService) {} + + @Get('settings') + @ResponseContract(wandererConnectionSchema) + getSettings(@CurrentUser() user: User) { + return this.wanderer.getConnection(user.id); + } + + @Put('settings') + @ResponseContract(wandererSaveResponseSchema) + saveSettings(@CurrentUser() user: User, @Body() body: WandererSettingsDto, @Req() req: Request) { + return this.wanderer.save(user.id, body, getClientIp(req)); + } + + @Delete('settings') + @HttpCode(200) + @ResponseContract(successResponseSchema) + async disconnect(@CurrentUser() user: User, @Req() req: Request) { + await this.wanderer.disconnect(user.id, getClientIp(req)); + return { success: true as const }; + } + + @Post('test') + @HttpCode(200) + @ResponseContract(wandererTestResponseSchema) + test(@CurrentUser() user: User, @Body() body: WandererTestDto) { + return this.wanderer.test(user.id, body); + } +} diff --git a/server/src/nest/wanderer/wanderer.dto.ts b/server/src/nest/wanderer/wanderer.dto.ts new file mode 100644 index 000000000..5a302d888 --- /dev/null +++ b/server/src/nest/wanderer/wanderer.dto.ts @@ -0,0 +1,11 @@ +/** + * createZodDto wrappers over the @trek/shared wanderer contracts, so the global + * ZodValidationPipe validates the bodies by metatype and the shared schemas + * stay the one source of truth. + */ +import { wandererSettingsRequestSchema, wandererTestRequestSchema } from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +export class WandererSettingsDto extends createZodDto(wandererSettingsRequestSchema) {} +export class WandererTestDto extends createZodDto(wandererTestRequestSchema) {} diff --git a/server/src/nest/wanderer/wanderer.helpers.ts b/server/src/nest/wanderer/wanderer.helpers.ts new file mode 100644 index 000000000..a678140ff --- /dev/null +++ b/server/src/nest/wanderer/wanderer.helpers.ts @@ -0,0 +1,11 @@ +/** Two instance addresses point at the same server when they share protocol, host and port. */ +export function sameHost(a: string | null, b: string): boolean { + if (!a) return false; + try { + const left = new URL(a); + const right = new URL(b); + return left.protocol === right.protocol && left.host === right.host; + } catch { + return false; + } +} diff --git a/server/src/nest/wanderer/wanderer.module.ts b/server/src/nest/wanderer/wanderer.module.ts new file mode 100644 index 000000000..28333b0a1 --- /dev/null +++ b/server/src/nest/wanderer/wanderer.module.ts @@ -0,0 +1,21 @@ +import { WandererConnections } from '../../db/entities/WandererConnections.entity'; +import { AddonsModule } from '../addons/addons.module'; +import { AuditModule } from '../audit/audit.module'; +import { WandererClient } from './wanderer.client'; +import { WandererController } from './wanderer.controller'; +import { WandererService } from './wanderer.service'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { Module } from '@nestjs/common'; + +/** + * The wanderer integration: a user's own connection to their wanderer + * instance and the reads the Tours import builds on. Tours imports this module + * for `WandererService`; the trip-side routes live there. + */ +@Module({ + imports: [MikroOrmModule.forFeature([WandererConnections]), AddonsModule, AuditModule], + controllers: [WandererController], + providers: [WandererClient, WandererService], + exports: [WandererService], +}) +export class WandererModule {} diff --git a/server/src/nest/wanderer/wanderer.service.ts b/server/src/nest/wanderer/wanderer.service.ts new file mode 100644 index 000000000..1e084776f --- /dev/null +++ b/server/src/nest/wanderer/wanderer.service.ts @@ -0,0 +1,249 @@ +import { WandererConnections } from '../../db/entities/WandererConnections.entity'; +import type { WandererConnectionsRepository } from '../../db/repositories/WandererConnections.repository'; +import { decodePolyline, thinLine } from '../../utils/polyline'; +import { checkSsrf } from '../../utils/ssrfGuard'; +import { AuditService } from '../audit/audit.service'; +import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto'; +import { DomainError } from '../common/domain-error'; +import { + difficultyOf, + instanceOrigin, + WandererClient, + WandererError, + type WandererCreds, + type WandererSearchHit, + type WandererTrailRecord, +} from './wanderer.client'; +import { sameHost } from './wanderer.helpers'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; +import { + WANDERER_SEARCH_PAGE_SIZE, + WANDERER_TOKEN_MASK, + type WandererConnection, + type WandererErrorCode, + type WandererSaveResponse, + type WandererSearchRequest, + type WandererSettingsRequest, + type WandererTestRequest, + type WandererTestResponse, + type WandererTrailSummary, +} from '@trek/shared'; + +/** A list row's line: enough points to read the shape, few enough to send twenty of them. */ +const SUMMARY_LINE_POINTS = 120; + +/** A trail as found, before the trip says which of them it already holds. */ +export type WandererTrailFound = Omit; + +/** What a trip-side caller needs to import a trail: where it is, the record and its GPX. */ +export interface WandererTrailDownload { + instance: string; + trail: WandererTrailRecord; + gpx: string; +} + +/** An upstream failure as the API answers it: never a 401, which the app reads as its own session ending. */ +function refusalOf(err: WandererError): DomainError { + const status = err.code === 'not_found' ? 404 : err.code === 'forbidden' || err.code === 'not_own_trail' ? 403 : 502; + return new DomainError(status, err.message, { code: `wanderer_${err.code}`, details: { code: err.code } }); +} + +/** + * The wanderer connection of one user: their instance address and API token, + * the test, and the reads a trip's import builds on. The token is encrypted at + * rest and leaves only in the Authorization header of a call to the instance + * it was saved for. + */ +@Injectable() +export class WandererService { + constructor( + @InjectRepository(WandererConnections) private readonly connections: WandererConnectionsRepository, + private readonly client: WandererClient, + private readonly audit: AuditService, + ) {} + + async getConnection(userId: number): Promise { + const row = await this.connections.findRow(userId); + return { + url: row?.url ?? '', + tokenMasked: row?.token ? WANDERER_TOKEN_MASK : '', + allowInsecureTls: !!row?.allow_insecure_tls, + connected: !!(row?.url && row.token), + lastCheckedAt: row?.last_checked_at ?? null, + lastCheckOk: row?.last_check_ok == null ? null : row.last_check_ok === 1, + lastCheckError: (row?.last_check_error as WandererErrorCode | null | undefined) ?? null, + }; + } + + /** + * Save the address and, when a new one was typed, the token. A token belongs + * to the server it was minted on, so pointing the connection at another host + * without a new token drops the stored one rather than sending it there. + * A private address is saved with a warning: a home-network instance is the + * common case, not an attack. + */ + async save(userId: number, input: WandererSettingsRequest, clientIp: string | null): Promise { + const url = instanceOrigin(input.url); + const ssrf = await checkSsrf(url); + if (!ssrf.allowed && !ssrf.isPrivate) { + throw new DomainError(400, ssrf.error ?? 'Invalid wanderer URL', { + code: 'wanderer_invalid_url', + details: { code: 'invalid_url' }, + }); + } + const previous = await this.connections.findRow(userId); + const typed = input.token && input.token !== WANDERER_TOKEN_MASK ? input.token : undefined; + const hostChanged = !!previous && !sameHost(previous.url, url); + const token = typed !== undefined ? maybe_encrypt_api_key(typed) : hostChanged ? null : undefined; + await this.connections.save(userId, { url, allowInsecureTls: input.allowInsecureTls, token, hostChanged }); + await this.audit.writeAudit({ + userId, + action: previous ? 'wanderer.updated' : 'wanderer.connected', + ip: clientIp, + details: { url, token_changed: typed !== undefined, ...(ssrf.isPrivate ? { resolved_ip: ssrf.resolvedIp } : {}) }, + }); + return ssrf.isPrivate ? { success: true, warning: 'private_ip' } : { success: true }; + } + + /** Forget the connection. Tours imported through it stay: they are the trip's own. */ + async disconnect(userId: number, clientIp: string | null): Promise { + await this.connections.remove(userId); + await this.audit.writeAudit({ userId, action: 'wanderer.disconnected', ip: clientIp, details: {} }); + } + + /** + * Test what the form holds, each field falling back to the saved one, and + * remember the outcome when it tested the saved connection itself. + */ + async test(userId: number, input: WandererTestRequest): Promise { + const saved = await this.connections.findRow(userId); + const url = input.url ? instanceOrigin(input.url) : saved?.url; + const typed = input.token && input.token !== WANDERER_TOKEN_MASK ? input.token : undefined; + const token = + typed ?? (saved?.token && (!input.url || sameHost(saved.url, url ?? '')) ? decrypt_api_key(saved.token) : null); + if (!url || !token) return { ok: false, code: 'not_connected' }; + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) return { ok: false, code: ssrf.isPrivate ? 'blocked_address' : 'invalid_url' }; + const testsSaved = !!saved && typed === undefined && sameHost(saved.url, url); + try { + const { trailCount } = await this.client.check({ + baseUrl: url, + token, + allowInsecureTls: input.allowInsecureTls ?? !!saved?.allow_insecure_tls, + }); + if (testsSaved) await this.noteCheck(userId, true, null); + return { ok: true, trailCount }; + } catch (err: unknown) { + if (!(err instanceof WandererError)) throw err; + if (testsSaved) await this.noteCheck(userId, false, err.code); + return { ok: false, code: err.code }; + } + } + + /** The saved connection ready for a call; a refusal the client can act on when there is none. */ + private async credentials(userId: number): Promise { + const row = await this.connections.findRow(userId); + const token = row?.token ? decrypt_api_key(row.token) : null; + if (!row?.url || !token) { + throw new DomainError(409, 'wanderer is not connected', { + code: 'wanderer_not_connected', + details: { code: 'not_connected' }, + }); + } + return { baseUrl: row.url, token, allowInsecureTls: !!row.allow_insecure_tls }; + } + + /** + * Note on the connection what the instance last said, for its settings card. + * @txStandalone a status note that stands on its own: it is true whatever the call around it does next + */ + private async noteCheck(userId: number, ok: boolean, code: WandererErrorCode | null): Promise { + await this.connections.recordCheck(userId, ok, code); + } + + /** A failed call, with a rejected token noted on the connection so its card says so. */ + private async failed(userId: number, err: unknown): Promise { + if (!(err instanceof WandererError)) throw err; + if (err.code === 'unauthorized') await this.noteCheck(userId, false, err.code); + throw refusalOf(err); + } + + /** The instance this user's connection points at, as the key a snapshot records. */ + async instanceOf(userId: number): Promise { + return instanceOrigin((await this.credentials(userId)).baseUrl); + } + + async search( + userId: number, + request: Required> & Pick, + ): Promise<{ instance: string; trails: WandererTrailFound[]; total: number; pageSize: number }> { + const creds = await this.credentials(userId); + const instance = instanceOrigin(creds.baseUrl); + try { + const authors = await this.client.ownActors(creds); + const { hits, total } = await this.client.search( + creds, + { q: request.q, page: request.page, bbox: request.bbox }, + authors, + ); + return { + instance, + trails: hits.map((hit) => summaryOf(instance, hit)), + total, + pageSize: WANDERER_SEARCH_PAGE_SIZE, + }; + } catch (err: unknown) { + return await this.failed(userId, err); + } + } + + /** One trail and its original GPX, for the import to measure and copy. */ + async download(userId: number, trailId: string): Promise { + const creds = await this.credentials(userId); + const instance = instanceOrigin(creds.baseUrl); + try { + const authors = await this.client.ownActors(creds); + const trail = await this.client.trail(creds, trailId); + // Only the connected account's own trails come in, whatever else its token can read. + if (!trail.author || !authors.includes(trail.author)) { + throw new WandererError('not_own_trail', 'Only your own wanderer trails can be imported'); + } + const gpx = await this.client.gpx(creds, trail); + return { instance, trail, gpx }; + } catch (err: unknown) { + return await this.failed(userId, err); + } + } + + /** The list row of a trail by its id, as the search would show it. */ + async findTrail(userId: number, trailId: string): Promise { + const creds = await this.credentials(userId); + const instance = instanceOrigin(creds.baseUrl); + try { + const hit = await this.client.searchById(creds, trailId, await this.client.ownActors(creds)); + return hit ? summaryOf(instance, hit) : null; + } catch (err: unknown) { + return await this.failed(userId, err); + } + } +} + +function summaryOf(instance: string, hit: WandererSearchHit): WandererTrailFound { + const author = hit.author_name ? (hit.domain ? `${hit.author_name}@${hit.domain}` : hit.author_name) : null; + return { + id: hit.id, + name: hit.name, + description: hit.description || null, + location: hit.location || null, + distanceM: hit.distance ?? null, + durationS: hit.duration || null, + elevationGainM: hit.elevation_gain ?? null, + elevationLossM: hit.elevation_loss ?? null, + difficulty: difficultyOf(hit.difficulty), + public: !!hit.public, + author, + line: hit.polyline ? thinLine(decodePolyline(hit.polyline, 5), SUMMARY_LINE_POINTS) : [], + externalUrl: `${instance}/trail/view/@${encodeURIComponent(author ?? '')}/${encodeURIComponent(hit.id)}`, + }; +} diff --git a/server/src/utils/polyline.ts b/server/src/utils/polyline.ts new file mode 100644 index 000000000..7922fbc03 --- /dev/null +++ b/server/src/utils/polyline.ts @@ -0,0 +1,65 @@ +/** + * Google-encoded polyline codec, shared by the domains that read one: the + * transit backends (Google merges a walk split across several steps and needs + * the encoder to put the joined path back on the wire) and wanderer, whose + * search results carry each trail's simplified line this way. The client + * carries its own decoder for drawing on the map + * (client/src/components/Map/transitGeometry.ts). + */ +export function decodePolyline(encoded: string, precision: number): [number, number][] { + const factor = 10 ** precision; + const points: [number, number][] = []; + let index = 0; + let lat = 0; + let lng = 0; + while (index < encoded.length) { + for (const axis of [0, 1] as const) { + let result = 0; + let shift = 0; + let byte = 0x20; + while (byte >= 0x20) { + if (index >= encoded.length) return points; + byte = (encoded.codePointAt(index++) ?? 0) - 63; + result |= (byte & 0x1f) << shift; + shift += 5; + } + const delta = result & 1 ? ~(result >> 1) : result >> 1; + if (axis === 0) lat += delta; + else lng += delta; + } + points.push([lat / factor, lng / factor]); + } + return points; +} + +function encodeSigned(value: number): string { + let remaining = value < 0 ? ~(value << 1) : value << 1; + let out = ''; + while (remaining >= 0x20) { + out += String.fromCharCode((0x20 | (remaining & 0x1f)) + 63); + remaining >>= 5; + } + return out + String.fromCharCode(remaining + 63); +} + +export function encodePolyline(points: [number, number][], precision: number): string { + const factor = 10 ** precision; + let out = ''; + let lastLat = 0; + let lastLng = 0; + for (const [lat, lng] of points) { + const scaledLat = Math.round(lat * factor); + const scaledLng = Math.round(lng * factor); + out += encodeSigned(scaledLat - lastLat) + encodeSigned(scaledLng - lastLng); + lastLat = scaledLat; + lastLng = scaledLng; + } + return out; +} + +/** At most `max` points, evenly picked, the first and the last always kept. */ +export function thinLine(points: readonly T[], max: number): T[] { + if (points.length <= max) return [...points]; + const step = (points.length - 1) / (max - 1); + return Array.from({ length: max }, (_, i) => points[Math.round(i * step)]); +} diff --git a/server/tests/e2e/tours.e2e.test.ts b/server/tests/e2e/tours.e2e.test.ts index 2c93f8ac6..0ff0a7a0c 100644 --- a/server/tests/e2e/tours.e2e.test.ts +++ b/server/tests/e2e/tours.e2e.test.ts @@ -502,6 +502,127 @@ describe('Tours e2e (real guards + temp SQLite)', () => { }); }); + describe('planning over an import (PR1-05)', () => { + /** An imported GPX tour on trip 5: no waypoints, the recorded line as it came. */ + const importedTour = async (tripId = 5) => { + const res = await importGpx(OWNER, TRACK_GPX, tripId); + expect(res.status).toBe(201); + return res.body.tours[0].place_id as number; + }; + const listed = async (placeId: number, tripId = 5) => + (await request(server).get(`/api/trips/${tripId}/tours`).set('Cookie', sessionCookie(OWNER))).body.tours.find( + (tour: { place_id: number }) => tour.place_id === placeId, + ); + const restore = (placeId: number, userId = OWNER, tripId = 5) => + request(server) + .post(`/api/trips/${tripId}/tours/${placeId}/restore-recorded`) + .set('Cookie', sessionCookie(userId)); + + it('a new tour planned from an import remembers it; the import stays as it was', async () => { + const source = await importedTour(); + const recorded = (await placeColumns(source, 'route_geometry')).route_geometry; + const derived = await createViaApi(5, tourBody({ derived_from_place_id: source })); + + expect(await listed(derived)).toMatchObject({ derived_from_place_id: source, has_waypoints: true }); + expect(await listed(source)).toMatchObject({ + derived_from_place_id: null, + has_waypoints: false, + has_recorded_track: false, + }); + expect((await placeColumns(source, 'route_geometry')).route_geometry).toBe(recorded); + + // Deleting the import keeps the derived tour and forgets where it came from. + await request(server).delete(`/api/trips/5/places/${source}`).set('Cookie', sessionCookie(OWNER)); + expect(await listed(derived)).toMatchObject({ derived_from_place_id: null }); + }); + + it('400 for an origin that is a planned tour, a tour of another trip or no tour at all', async () => { + const planned = await createViaApi(); + const foreign = await importedTour(6); + const plain = await plainPlace(5, 'Cafe'); + // One request per origin at once; each answers on its own. + const answers = await Promise.all( + [planned, foreign, plain, 999_999].map((id) => + request(server) + .post('/api/trips/5/tours') + .set('Cookie', sessionCookie(OWNER)) + .send(tourBody({ derived_from_place_id: id })), + ), + ); + expect(answers.map((res) => res.status)).toEqual([400, 400, 400, 400]); + expect(await countRows(orm, Tours, { place: { $nin: [planned, foreign] } })).toBe(0); + }); + + it('planning an import over in place keeps its recorded track, and restoring brings it back', async () => { + const placeId = await importedTour(); + const recorded = (await placeColumns(placeId, 'route_geometry')).route_geometry; + const before = await listed(placeId); + + const put = await request(server) + .put(`/api/trips/5/tours/${placeId}`) + .set('Cookie', sessionCookie(OWNER)) + .send(tourBody()); + expect(put.status).toBe(200); + expect(await listed(placeId)).toMatchObject({ has_waypoints: true, has_recorded_track: true }); + // A second edit does not overwrite the track kept the first time. + await request(server) + .put(`/api/trips/5/tours/${placeId}`) + .set('Cookie', sessionCookie(OWNER)) + .send(tourBody({ name: 'Edited again' })); + + broadcast.mockClear(); + const res = await restore(placeId); + expect(res.status).toBe(200); + // An import's detail names its two ends from the line, as before it was planned over. + expect(res.body.waypoints.map((point: { role: string }) => point.role)).toEqual(['start', 'end']); + expect(res.body.tour).toMatchObject({ + place_id: placeId, + has_waypoints: false, + has_recorded_track: false, + distance: before.distance, + elevation_gain: before.elevation_gain, + elevation_loss: before.elevation_loss, + max_hiking_difficulty: null, + }); + expect((await placeColumns(placeId, 'route_geometry')).route_geometry).toBe(recorded); + expect(await countRows(orm, TourWaypoints, { place: placeId })).toBe(0); + expect(broadcast.mock.calls.map((c) => c[1])).toEqual(expect.arrayContaining(['tours:changed', 'place:updated'])); + }); + + it('409 restoring a tour that keeps no recorded track; 403 without place_edit; 404 for another trip', async () => { + const planned = await createViaApi(); + await request(server) + .put(`/api/trips/5/tours/${planned}`) + .set('Cookie', sessionCookie(OWNER)) + .send(tourBody({ name: 'Edited' })); + const none = await restore(planned); + expect(none.status).toBe(409); + expect(none.body).toMatchObject({ code: 'no_recorded_track' }); + + const replaced = await importedTour(); + await request(server).put(`/api/trips/5/tours/${replaced}`).set('Cookie', sessionCookie(OWNER)).send(tourBody()); + expect((await restore(replaced, VIEWER)).status).toBe(403); + expect((await restore(replaced, OWNER, 6)).status).toBe(404); + expect(await listed(replaced)).toMatchObject({ has_recorded_track: true }); + }); + + it('copying the trip points a derived tour at the copied import, never back into the source', async () => { + const source = await importedTour(); + await createViaApi(5, tourBody({ name: 'Planned from import', derived_from_place_id: source })); + const res = await request(server) + .post('/api/trips/5/copy') + .set('Cookie', sessionCookie(OWNER)) + .send({ title: 'Copy' }); + const copyId = res.body.trip.id as number; + const tours = (await request(server).get(`/api/trips/${copyId}/tours`).set('Cookie', sessionCookie(OWNER))).body + .tours; + const copiedImport = tours.find((tour: { name: string }) => tour.name === 'Alpine loop'); + const copiedDerived = tours.find((tour: { name: string }) => tour.name === 'Planned from import'); + expect(copiedDerived.derived_from_place_id).toBe(copiedImport.place_id); + expect(copiedDerived.derived_from_place_id).not.toBe(source); + }); + }); + describe('other domains', () => { it('409 moving a tour onto a day that already holds it, on create and on move', async () => { const placeId = await createViaApi(); diff --git a/server/tests/e2e/wanderer.e2e.test.ts b/server/tests/e2e/wanderer.e2e.test.ts new file mode 100644 index 000000000..108ae4843 --- /dev/null +++ b/server/tests/e2e/wanderer.e2e.test.ts @@ -0,0 +1,392 @@ +/** + * wanderer e2e: the personal connection (/api/integrations/wanderer) and the + * trip-side search, preview and import (/api/trips/:tripId/tours/wanderer) + * through the real JwtAuthGuard, AddonGuard, WandererEnabledGuard and + * TripAccessGuard, over the real services and repositories on a migrated temp + * SQLite db handed to them. + * + * Replaced: `WandererClient`, so no test ever opens a socket to an instance + * (each method is a spy answering from the fixtures below), `checkSsrf`, whose + * DNS lookup is not what this file is about, the permission check (a spy) and + * the journey hooks. Broadcasts go to a FakeRealtimeService. + */ +import { Places } from '../../src/db/entities/Places.entity'; +import { TourSources } from '../../src/db/entities/TourSources.entity'; +import { Trips } from '../../src/db/entities/Trips.entity'; +import { WandererConnections } from '../../src/db/entities/WandererConnections.entity'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; +import { PermissionsService } from '../../src/nest/permissions/permissions.service'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { RealtimeService } from '../../src/nest/realtime/realtime.service'; +import { ToursModule } from '../../src/nest/tours/tours.module'; +import { WandererClient, WandererError, type WandererSearchHit } from '../../src/nest/wanderer/wanderer.client'; +import { WandererModule } from '../../src/nest/wanderer/wanderer.module'; +import { createSnapshotTestDb } from '../helpers/db-mock'; +import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows'; +import { setAddonEnabled, setAppSetting } from '../helpers/factories/settings'; +import { addTripMember } from '../helpers/factories/trips'; +import { makeUser } from '../helpers/factories/users'; +import { FakeRealtimeService } from '../helpers/fake-realtime'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { TestUnitOfWorkModule } from '../helpers/test-uow'; +import { sessionCookie } from './harness'; +import { Test } from '@nestjs/testing'; + +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; + +vi.mock('../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', + logInfo: vi.fn(), + logDebug: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), +})); +const { checkSsrf } = vi.hoisted(() => ({ checkSsrf: vi.fn() })); +vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({ + ...(await importOriginal()), + checkSsrf, +})); + +const { journeyHooks } = vi.hoisted(() => ({ + journeyHooks: { + reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined), + onPlaceCreated: vi.fn().mockResolvedValue(undefined), + onPlaceUpdated: vi.fn().mockResolvedValue(undefined), + onPlaceDeleted: vi.fn().mockResolvedValue(undefined), + }, +})); + +/** A migrated temp database handed to the code under test, rather than a mocked database module. */ +const db = createSnapshotTestDb(); + +const OWNER = 1; +const VIEWER = 2; +const OUTSIDER = 3; +const TOKEN = 'wanderer_key_abcdef'; + +const HIT: WandererSearchHit = { + id: 'ukzj2hazx6dgzam', + name: 'Partnachklamm', + description: 'Through the gorge', + location: 'Garmisch', + distance: 2000, + duration: 3600, + elevation_gain: 150, + elevation_loss: 150, + difficulty: 1, + public: false, + author_name: 'maurice', + domain: 'wanderer.example.org', + polyline: '_p~iF~ps|U_ulLnnqC', +}; +const GPX = ` +Partnachklamm + 700 + 800 + 850 +`; +const RECORD = { + id: HIT.id, + name: HIT.name, + description: HIT.description, + gpx: 'track.gpx', + updated: '2026-10-01', + public: false, + author: 'actor1', +}; + +const realtime = new FakeRealtimeService(); +const client = { + check: vi.fn(), + ownActors: vi.fn(), + search: vi.fn(), + searchById: vi.fn(), + trail: vi.fn(), + gpx: vi.fn(), +}; + +let orm: TestOrm; +let server: Server; +let app: Awaited>; +let checkPermission: MockInstance; + +async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [ + await TestUnitOfWorkModule.forRoot(db), + await createTestMikroOrmModule(db), + RealtimeModule, + WandererModule, + ToursModule, + ], + }) + .overrideProvider(RealtimeService) + .useValue(realtime) + .overrideProvider(JourneyDomainService) + .useValue(journeyHooks) + .overrideProvider(WandererClient) + .useValue(client) + .compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalPipes(new ZodValidationPipe()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; +} + +const as = (userId: number) => ({ Cookie: sessionCookie(userId) }); +const connect = (userId = OWNER) => + request(server) + .put('/api/integrations/wanderer/settings') + .set(as(userId)) + .send({ url: 'https://wanderer.example.org/', token: TOKEN }); +const search = (body: Record = {}, userId = OWNER, tripId = 5) => + request(server).post(`/api/trips/${tripId}/tours/wanderer/search`).set(as(userId)).send(body); +const preview = (trailId = HIT.id, userId = OWNER) => + request(server).get(`/api/trips/5/tours/wanderer/trails/${trailId}`).set(as(userId)); +const importTrail = (contentHash: string, userId = OWNER) => + request(server).post('/api/trips/5/tours/wanderer/import').set(as(userId)).send({ trailId: HIT.id, contentHash }); + +beforeAll(async () => { + orm = await createTestOrm(db); + await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 }); + await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 }); + await makeUser(orm, { + id: 3, + username: 'outsider', + email: 'outsider@example.test', + role: 'user', + password_version: 0, + }); + await insertRows(orm, Trips, [{ id: 5, user: 1, title: 'Alps' }]); + await addTripMember(orm, 5, 2); + app = await build(); + checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); + server = app.getHttpServer(); +}); + +beforeEach(async () => { + vi.clearAllMocks(); + checkPermission.mockImplementation( + (action: string, _role: string, _owner: number, userId: number) => !(action === 'place_edit' && userId === VIEWER), + ); + checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false }); + client.check.mockResolvedValue({ trailCount: 3 }); + client.ownActors.mockResolvedValue(['actor1']); + client.search.mockResolvedValue({ hits: [HIT], total: 1 }); + client.searchById.mockResolvedValue(HIT); + client.trail.mockResolvedValue(RECORD); + client.gpx.mockResolvedValue(GPX); + await setAddonEnabled(orm, 'tours', true); + await setAppSetting(orm, 'tours_wanderer_enabled', 'true'); + await deleteRows(orm, TourSources); + await deleteRows(orm, Places); + await deleteRows(orm, WandererConnections); +}); + +afterAll(async () => { + await app.close(); + await orm.close(); +}); + +describe('wanderer e2e', () => { + describe('the connection', () => { + it('RS-03: saves a connection, keeps the token encrypted and never hands it back', async () => { + expect((await connect()).body).toEqual({ success: true }); + const row = await findRow(orm, WandererConnections, { user: OWNER }); + expect(row?.token).toBeTruthy(); + expect(row?.token).not.toContain(TOKEN); + + const res = await request(server).get('/api/integrations/wanderer/settings').set(as(OWNER)); + expect(res.body).toMatchObject({ url: 'https://wanderer.example.org', tokenMasked: '••••••••', connected: true }); + expect(JSON.stringify(res.body)).not.toContain(TOKEN); + }); + + it('RS-03: a private address is saved with a warning, an invalid one refused', async () => { + checkSsrf.mockResolvedValueOnce({ allowed: false, isPrivate: true, resolvedIp: '10.0.0.5' }); + expect((await connect()).body).toEqual({ success: true, warning: 'private_ip' }); + + checkSsrf.mockResolvedValueOnce({ allowed: false, isPrivate: false, error: 'Invalid URL' }); + const refused = await connect(); + expect(refused.status).toBe(400); + expect(refused.body).toMatchObject({ code: 'invalid_url' }); + }); + + it('RS-03: a new host without a new token drops the stored one', async () => { + await connect(); + await request(server) + .put('/api/integrations/wanderer/settings') + .set(as(OWNER)) + .send({ url: 'https://other.example.org' }); + expect((await findRow(orm, WandererConnections, { user: OWNER }))?.token).toBeNull(); + }); + + it('RS-03: the test answers with the reason in the body and notes it on the saved connection', async () => { + await connect(); + expect((await request(server).post('/api/integrations/wanderer/test').set(as(OWNER)).send({})).body).toEqual({ + ok: true, + trailCount: 3, + }); + + client.check.mockRejectedValueOnce(new WandererError('unauthorized', 'no')); + expect((await request(server).post('/api/integrations/wanderer/test').set(as(OWNER)).send({})).body).toEqual({ + ok: false, + code: 'unauthorized', + }); + expect((await request(server).get('/api/integrations/wanderer/settings').set(as(OWNER))).body).toMatchObject({ + lastCheckOk: false, + lastCheckError: 'unauthorized', + }); + }); + + it('RS-03: disconnecting forgets only the caller connection', async () => { + await connect(OWNER); + await connect(VIEWER); + expect((await request(server).delete('/api/integrations/wanderer/settings').set(as(OWNER))).status).toBe(200); + expect(await countRows(orm, WandererConnections, { user: OWNER })).toBe(0); + expect(await countRows(orm, WandererConnections, { user: VIEWER })).toBe(1); + }); + + it('404 for every wanderer route while the admin has not allowed it under Tours, or Tours is off', async () => { + await connect(); + await setAppSetting(orm, 'tours_wanderer_enabled', 'false'); + const settings = await request(server).get('/api/integrations/wanderer/settings').set(as(OWNER)); + expect(settings.status).toBe(404); + expect(settings.body).toMatchObject({ code: 'disabled' }); + expect((await search()).status).toBe(404); + + await setAppSetting(orm, 'tours_wanderer_enabled', 'true'); + await setAddonEnabled(orm, 'tours', false); + expect((await search()).status).toBe(404); + expect(client.search).not.toHaveBeenCalled(); + }); + }); + + describe('search, preview and import', () => { + it('RS-04: 409 not_connected before a connection exists', async () => { + const res = await search(); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ code: 'not_connected' }); + }); + + it('RS-04/MF-02: searches the caller own trails, in the map box when asked, and marks what is in the trip', async () => { + await connect(); + const box = { south: 47, west: 11, north: 48, east: 12 }; + const res = await search({ q: 'klamm', bbox: box }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + total: 1, + trails: [{ id: HIT.id, name: 'Partnachklamm', difficulty: 'moderate', importedAs: [] }], + }); + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ token: TOKEN }), + { q: 'klamm', page: 1, bbox: box }, + ['actor1'], + ); + }); + + it('RS-04: only who may add places to the trip looks; an outsider learns nothing of it', async () => { + await connect(VIEWER); + expect((await search({}, VIEWER)).status).toBe(403); + expect((await search({}, OUTSIDER)).status).toBe(404); + }); + + it('RS-05: previews, then imports what was previewed as a snapshot with its provenance', async () => { + await connect(); + const shown = await preview(); + expect(shown.status).toBe(200); + expect(shown.body).toMatchObject({ state: 'new', hasElevation: true, points: 3 }); + + const res = await importTrail(shown.body.contentHash); + expect(res.status).toBe(201); + expect(res.body.tour).toMatchObject({ + name: 'Partnachklamm', + has_waypoints: false, + difficulty: 'moderate', + max_hiking_difficulty: 2, + }); + const placeId = res.body.tour.place_id as number; + expect(await findRow(orm, TourSources, { place: placeId })).toMatchObject({ + provider: 'wanderer', + external_id: HIT.id, + }); + + // Imported once, the same content is not imported again. + const again = await importTrail(shown.body.contentHash); + expect(again.status).toBe(409); + expect(again.body).toMatchObject({ code: 'already_imported' }); + expect((await search()).body.trails[0].importedAs).toEqual([placeId]); + }); + + it('RS-05: a fingerprint that no longer matches the trail imports nothing', async () => { + await connect(); + const res = await importTrail('0'.repeat(64)); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ code: 'preview_stale' }); + expect(await countRows(orm, Places)).toBe(0); + }); + + it('RS-06: a changed trail previews as changed and comes in as a new snapshot beside the old one', async () => { + await connect(); + const first = await importTrail((await preview()).body.contentHash); + client.trail.mockResolvedValue({ ...RECORD, name: 'Partnachklamm (new route)' }); + const changed = await preview(); + expect(changed.body).toMatchObject({ state: 'changed', snapshot: { placeId: first.body.tour.place_id } }); + expect(changed.body.changes).toContain('name'); + + const second = await importTrail(changed.body.contentHash); + expect(second.status).toBe(201); + expect(second.body.tour.place_id).not.toBe(first.body.tour.place_id); + expect(await countRows(orm, Places)).toBe(2); + }); + + it('RS-05: a trail without a track or route is refused before anything is written', async () => { + await connect(); + client.gpx.mockResolvedValue(''); + const res = await preview(); + expect(res.status).toBe(422); + expect(res.body).toMatchObject({ code: 'no_track' }); + expect(await countRows(orm, Places)).toBe(0); + }); + + it('RS-05: a trail without heights comes in marked to use with caution', async () => { + await connect(); + client.gpx.mockResolvedValue(GPX.replace(/\d+<\/ele>/g, '')); + client.trail.mockResolvedValue({ ...RECORD, description: ' ' }); + const shown = await preview(); + expect(shown.body).toMatchObject({ hasElevation: false, elevationGainM: null }); + const res = await importTrail(shown.body.contentHash); + expect(res.body.tour).toMatchObject({ caution: true, description: null }); + }); + + it('RS-06: says whether a snapshot was edited here since its import, and when nothing changed there', async () => { + await connect(); + const imported = await importTrail((await preview()).body.contentHash); + expect((await preview()).body).toMatchObject({ + state: 'unchanged', + changes: [], + snapshot: { editedLocally: false }, + }); + + await request(server) + .put(`/api/trips/5/places/${imported.body.tour.place_id}`) + .set(as(OWNER)) + .send({ name: 'Renamed here' }); + expect((await preview()).body).toMatchObject({ state: 'unchanged', snapshot: { editedLocally: true } }); + }); + + it("someone else's trail is neither previewed nor imported", async () => { + await connect(); + client.searchById.mockResolvedValue(null); + expect((await preview()).status).toBe(404); + expect((await importTrail('0'.repeat(64))).status).toBe(404); + expect(client.gpx).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/tests/unit/db/known-migrations.test.ts b/server/tests/unit/db/known-migrations.test.ts index 761822334..af4b14af6 100644 --- a/server/tests/unit/db/known-migrations.test.ts +++ b/server/tests/unit/db/known-migrations.test.ts @@ -16,6 +16,10 @@ describe('known migrations', () => { expect(known.has('Migration20200101043100_an_imported_tour_has_no_hiking_difficulty')).toBe(true); expect(known.has('Migration20200101043200_via_ferrata_tiles_outlive_a_restart')).toBe(true); expect(known.has('Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata')).toBe(true); + expect(known.has('Migration20200101043400_wanderer_connection_and_tour_sources')).toBe(true); + expect(known.has('Migration20200101043500_wanderer_difficulty_on_the_sac_scale')).toBe(true); + expect(known.has('Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from')).toBe(true); + expect(known.has('Migration20200101043700_an_import_planned_over_keeps_its_recorded_track')).toBe(true); expect(known.size).toBe(fs.readdirSync(path.join(__dirname, '../../../src/db/migrations')).length); }); diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index 345097e53..adb67878e 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -45,6 +45,10 @@ const UNNUMBERED = [ 'Migration20200101043100_an_imported_tour_has_no_hiking_difficulty', 'Migration20200101043200_via_ferrata_tiles_outlive_a_restart', 'Migration20200101043300_a_tour_leg_can_follow_a_via_ferrata', + 'Migration20200101043400_wanderer_connection_and_tour_sources', + 'Migration20200101043500_wanderer_difficulty_on_the_sac_scale', + 'Migration20200101043600_a_tour_remembers_the_import_it_was_derived_from', + 'Migration20200101043700_an_import_planned_over_keeps_its_recorded_track', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 78b829dbf..28a093093 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -410,6 +410,21 @@ describe('AddonsService addon/feature flags', () => { expect(await storedAppSetting('bag_tracking_enabled')).toEqual({ key: 'bag_tracking_enabled', value: 'false' }); }); + it('wanderer under Tours is opt-in and usable only while Tours itself is on', async () => { + expect(await svc.getToursWanderer()).toEqual({ enabled: false }); // absent row → OFF + expect(await svc.updateToursWanderer(true)).toEqual({ enabled: true }); + expect(await storedAppSetting('tours_wanderer_enabled')).toEqual({ key: 'tours_wanderer_enabled', value: 'true' }); + + expect(await svc.isWandererAvailable()).toBe(false); // Tours is off + await insertAddon({ id: 'tours', name: 'Tours', enabled: 1 }); + expect(await svc.isWandererAvailable()).toBe(true); + expect((await svc.list()).toursWanderer).toBe(true); + + await svc.updateToursWanderer(false); + expect(await svc.isWandererAvailable()).toBe(false); + expect((await svc.list()).toursWanderer).toBe(false); + }); + it('getCollabFeatures is opt-out: absent rows default ON, only false disables', async () => { await setAppSetting('collab_chat_enabled', 'false'); await setAppSetting('collab_polls_enabled', 'true'); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index d4d343fc0..7eb859463 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -44,6 +44,8 @@ const addonsStub = () => ({ getBagTracking: vi.fn(() => ({ enabled: false })), updateBagTracking: vi.fn((enabled: boolean) => ({ enabled })), + getToursWanderer: vi.fn(() => ({ enabled: false })), + updateToursWanderer: vi.fn((enabled: boolean) => ({ enabled })), getPlacesPhotos: vi.fn(() => ({ enabled: false })), updatePlacesPhotos: vi.fn((enabled: boolean) => ({ enabled })), getPlacesAutocomplete: vi.fn(() => ({ enabled: false })), @@ -489,6 +491,7 @@ describe('AdminController feature toggles', () => { const c = adminCtl(svc(), undefined, addons); const cases: Array<[() => Promise, string, keyof AddonsService]> = [ [() => c.updateBagTracking(user, { enabled: true }, req), 'admin.bag_tracking', 'updateBagTracking'], + [() => c.updateToursWanderer(user, { enabled: true }, req), 'admin.tours_wanderer', 'updateToursWanderer'], [() => c.updatePlacesPhotos(user, { enabled: true }, req), 'admin.places_photos', 'updatePlacesPhotos'], [ () => c.updatePlacesAutocomplete(user, { enabled: true }, req), @@ -509,6 +512,7 @@ describe('AdminController feature toggles', () => { it('ADMIN-TOGGLE-002 the getters return the AddonsService value verbatim', async () => { const c = adminCtl(svc()); expect(await c.getBagTracking()).toEqual({ enabled: false }); + expect(await c.getToursWanderer()).toEqual({ enabled: false }); expect(await c.getPlacesPhotos()).toEqual({ enabled: false }); expect(await c.getPlacesAutocomplete()).toEqual({ enabled: false }); expect(await c.getPlacesDetails()).toEqual({ enabled: false }); diff --git a/server/tests/unit/nest/wanderer.client.test.ts b/server/tests/unit/nest/wanderer.client.test.ts new file mode 100644 index 000000000..8ed570025 --- /dev/null +++ b/server/tests/unit/nest/wanderer.client.test.ts @@ -0,0 +1,259 @@ +/** + * WandererClient: what it puts on the wire and what it makes of the answers. + * + * `safeFetch` and `readCappedJson` are mocked (the SSRF guard does a real DNS + * lookup, the capped reader wants a real stream); the reader stub still parses + * the bytes the reply carries, so a non-JSON answer stays a real parse failure. + */ +import { + WandererClient, + WandererError, + bboxFilter, + difficultyOf, + instanceOrigin, + isTrailId, + type WandererCreds, +} from '../../../src/nest/wanderer/wanderer.client'; +import { ResponseTooLargeError, SsrfBlockedError } from '../../../src/utils/ssrfGuard'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { safeFetchMock, readCappedJsonMock } = vi.hoisted(() => ({ + safeFetchMock: vi.fn(), + readCappedJsonMock: vi.fn(), +})); +vi.mock('../../../src/utils/ssrfGuard', async (importOriginal) => ({ + ...(await importOriginal()), + safeFetch: safeFetchMock, +})); +vi.mock('../../../src/utils/cappedFetch', () => ({ readCappedJson: readCappedJsonMock })); + +const CREDS: WandererCreds = { + baseUrl: 'https://wanderer.example.org/api/v1/', + token: 'wanderer_key_abc', + allowInsecureTls: false, +}; + +interface Reply { + ok: boolean; + status: number; + text(): Promise; +} + +function reply(body: unknown, status = 200): Reply { + const raw = typeof body === 'string' ? body : JSON.stringify(body); + return { ok: status >= 200 && status < 300, status, text: async () => raw }; +} + +let calls: Array<{ url: string; init: RequestInit; options: { rejectUnauthorized?: boolean; maxBytes?: number } }> = []; + +/** Answers by path, so the order of the calls a method makes does not matter. */ +function answer(routes: Record Reply)>): void { + safeFetchMock.mockImplementation( + async (url: string, init: RequestInit, options: { rejectUnauthorized?: boolean }) => { + calls.push({ url, init, options }); + const path = new URL(url).pathname; + const route = routes[path]; + if (!route) throw new Error(`unexpected call to ${path}`); + return (typeof route === 'function' ? route(url) : route) as unknown as Response; + }, + ); +} + +const OWNER = { + '/api/v1/api-token': reply({ items: [{ user: 'gixauk8ukjpqabm' }], totalItems: 1 }), + '/api/v1/user/gixauk8ukjpqabm': reply({ + expand: { + activitypub_actors_via_user: [ + { id: 'nspsn7zu7octact', is_local: true }, + { id: 'remote1', is_local: false }, + ], + }, + }), +}; + +const HIT = { + id: 'ukzj2hazx6dgzam', + name: 'Partnachklamm', + description: 'Gorge', + location: 'Garmisch', + distance: 19_700, + duration: 18_000, + elevation_gain: 929, + elevation_loss: 929, + difficulty: 1, + public: true, + author_name: 'maurice', + domain: 'wanderer.example.org', + polyline: '_p~iF~ps|U_ulLnnqC', +}; + +function searchBody(index: number): { q: string; options: { filter?: string[]; limit?: number; offset?: number } } { + return JSON.parse(String(calls.filter((call) => call.url.endsWith('/search/trails'))[index].init.body)); +} + +async function failure(run: () => Promise): Promise { + try { + await run(); + } catch (err) { + if (err instanceof WandererError) return err; + throw err; + } + throw new Error('expected a WandererError'); +} + +beforeEach(() => { + calls = []; + safeFetchMock.mockReset(); + readCappedJsonMock.mockReset().mockImplementation(async (response: Reply) => JSON.parse(await response.text())); +}); + +describe('WandererClient', () => { + const client = new WandererClient(); + + it('RS-03: sends the token as a bearer header to the instance origin, never in the URL, with TLS checked', async () => { + answer({ ...OWNER, '/api/v1/search/trails': reply({ hits: [], estimatedTotalHits: 4 }) }); + await expect(client.check(CREDS)).resolves.toEqual({ trailCount: 4 }); + + for (const call of calls) { + expect(call.url.startsWith('https://wanderer.example.org/api/v1/')).toBe(true); + expect(call.url).not.toContain('wanderer_key_abc'); + expect((call.init.headers as Record).Authorization).toBe('Bearer wanderer_key_abc'); + expect(call.options.rejectUnauthorized).toBe(true); + expect(call.init.signal).toBeInstanceOf(AbortSignal); + } + // The count is of the owner's own trails only. + expect(searchBody(0).options.filter).toEqual(["author IN ['nspsn7zu7octact']"]); + }); + + it('RS-03: a token wanderer reads as anonymous is refused, however 200 the answer', async () => { + answer({ '/api/v1/api-token': reply({ items: [], totalItems: 0 }) }); + expect((await failure(() => client.check(CREDS))).code).toBe('unauthorized'); + }); + + it('RS-03: an owner without a local author, or with an odd id, is an answer TREK does not trust', async () => { + answer({ + ...OWNER, + '/api/v1/user/gixauk8ukjpqabm': reply({ + expand: { activitypub_actors_via_user: [{ id: 'remote1', is_local: false }] }, + }), + }); + expect((await failure(() => client.ownActors(CREDS))).code).toBe('invalid_response'); + + answer({ '/api/v1/api-token': reply({ items: [{ user: 'Not-An-Id!' }] }) }); + expect((await failure(() => client.ownActors(CREDS))).code).toBe('invalid_response'); + }); + + it('RS-04/MF-02: searches the owner trails, a page at a time, in the box when one is given', async () => { + answer({ '/api/v1/search/trails': reply({ hits: [HIT], totalHits: 31 }) }); + const box = { south: 47, west: 11, north: 48, east: 12 }; + await expect(client.search(CREDS, { q: 'klamm', page: 2, bbox: box }, ['nspsn7zu7octact'])).resolves.toMatchObject({ + total: 31, + hits: [HIT], + }); + + const body = searchBody(0); + expect(body.q).toBe('klamm'); + expect(body.options).toMatchObject({ limit: 20, offset: 20 }); + expect(body.options.filter).toEqual(["author IN ['nspsn7zu7octact']", bboxFilter(box)]); + }); + + it('MF-02: a box over the antimeridian takes either side of it', () => { + expect(bboxFilter({ south: -20, west: 170, north: -10, east: -170 })).toBe( + 'max_lat >= -20 AND min_lat <= -10 AND (max_lon >= 170 OR min_lon <= -170)', + ); + expect(bboxFilter({ south: 47, west: 11, north: 48, east: 12 })).toBe( + 'max_lat >= 47 AND min_lat <= 48 AND max_lon >= 11 AND min_lon <= 12', + ); + }); + + it('RS-05: finds one of the owner trails by id, and never puts an odd id into a filter', async () => { + answer({ '/api/v1/search/trails': reply({ hits: [HIT] }) }); + await expect(client.searchById(CREDS, 'ukzj2hazx6dgzam', ['nspsn7zu7octact'])).resolves.toEqual(HIT); + expect(searchBody(0).options.filter).toEqual(["id = 'ukzj2hazx6dgzam'", "author IN ['nspsn7zu7octact']"]); + + await expect(client.searchById(CREDS, "x' OR 1=1", ['nspsn7zu7octact'])).resolves.toBeNull(); + expect(calls).toHaveLength(1); + }); + + it('RS-05: reads a trail record and its original GPX', async () => { + answer({ + '/api/v1/trail/ukzj2hazx6dgzam': reply({ + id: 'ukzj2hazx6dgzam', + name: 'Partnachklamm', + gpx: 'track.gpx', + author: 'nspsn7zu7octact', + }), + '/api/v1/files/trails/ukzj2hazx6dgzam/track.gpx': reply(''), + }); + const trail = await client.trail(CREDS, 'ukzj2hazx6dgzam'); + expect(trail.author).toBe('nspsn7zu7octact'); + await expect(client.gpx(CREDS, trail)).resolves.toBe(''); + expect(calls[1].options.maxBytes).toBe(10 * 1024 * 1024); + }); + + it('RS-05: a trail without a file, or an id that is no record id, is not found', async () => { + answer({}); + expect((await failure(() => client.trail(CREDS, '../etc'))).code).toBe('not_found'); + expect((await failure(() => client.gpx(CREDS, { id: 'abc', name: 'x', gpx: null }))).code).toBe('not_found'); + expect(calls).toHaveLength(0); + }); + + it('RS-03: tells each way an instance can fail apart', async () => { + const statusOf = async (status: number, body = '') => { + answer({ '/api/v1/trail/abc': reply(body, status) }); + return (await failure(() => client.trail(CREDS, 'abc'))).code; + }; + expect(await statusOf(401)).toBe('unauthorized'); + expect(await statusOf(500, 'Failed to verify API token: Invalid or revoked API token')).toBe('unauthorized'); + expect(await statusOf(403)).toBe('forbidden'); + expect(await statusOf(404)).toBe('not_found'); + expect(await statusOf(502, 'Bad gateway')).toBe('server_error'); + + answer({ '/api/v1/trail/abc': reply('login') }); + expect((await failure(() => client.trail(CREDS, 'abc'))).code).toBe('invalid_response'); + + answer({ '/api/v1/trail/abc': reply({ unexpected: true }) }); + expect((await failure(() => client.trail(CREDS, 'abc'))).code).toBe('invalid_response'); + }); + + it('RS-03: transport failures name the guard, the certificate, the size or the reach', async () => { + const transport = async (error: unknown) => { + safeFetchMock.mockRejectedValueOnce(error); + return (await failure(() => client.trail(CREDS, 'abc'))).code; + }; + expect(await transport(new SsrfBlockedError('private'))).toBe('blocked_address'); + expect( + await transport(Object.assign(new Error('fetch failed'), { cause: { code: 'DEPTH_ZERO_SELF_SIGNED_CERT' } })), + ).toBe('tls'); + expect(await transport(new ResponseTooLargeError(1))).toBe('too_large'); + expect(await transport(new Error('ECONNREFUSED'))).toBe('unreachable'); + }); + + it('RS-03: a self-signed certificate is accepted only when the user allowed it', async () => { + answer({ '/api/v1/trail/abc': reply({ id: 'abc', name: 'x' }) }); + await client.trail({ ...CREDS, allowInsecureTls: true }, 'abc'); + expect(calls[0].options.rejectUnauthorized).toBe(false); + }); +}); + +describe('wanderer client helpers', () => { + it('reads the origin from whatever was pasted', () => { + expect(instanceOrigin('https://w.example.org/')).toBe('https://w.example.org'); + expect(instanceOrigin(' https://w.example.org/api ')).toBe('https://w.example.org'); + expect(instanceOrigin('https://w.example.org/sub/api/v1')).toBe('https://w.example.org/sub'); + }); + + it('knows a record id and the three difficulties', () => { + expect(isTrailId('ukzj2hazx6dgzam')).toBe(true); + expect(isTrailId('UPPER')).toBe(false); + expect(isTrailId('')).toBe(false); + expect([0, 1, 2, 3, null].map((value) => difficultyOf(value))).toEqual([ + 'easy', + 'moderate', + 'difficult', + null, + null, + ]); + }); +}); diff --git a/server/tests/unit/utils/polyline.test.ts b/server/tests/unit/utils/polyline.test.ts new file mode 100644 index 000000000..1500366ec --- /dev/null +++ b/server/tests/unit/utils/polyline.test.ts @@ -0,0 +1,43 @@ +import { sameHost } from '../../../src/nest/wanderer/wanderer.helpers'; +import { decodePolyline, encodePolyline, thinLine } from '../../../src/utils/polyline'; + +import { describe, expect, it } from 'vitest'; + +describe('polyline', () => { + it('decodes the reference line of the encoded polyline format', () => { + expect(decodePolyline('_p~iF~ps|U_ulLnnqC_mqNvxq`@', 5)).toEqual([ + [38.5, -120.2], + [40.7, -120.95], + [43.252, -126.453], + ]); + }); + + it('round-trips a line at either precision', () => { + const line: [number, number][] = [ + [47.42123, 10.98456], + [47.43001, 11.00002], + [-33.5, 151.25], + ]; + expect(decodePolyline(encodePolyline(line, 5), 5)).toEqual(line); + expect(decodePolyline(encodePolyline(line, 6), 6)).toEqual(line); + }); + + it('thins a long line to at most the given count, keeping its ends', () => { + const line = Array.from({ length: 1000 }, (_, i) => i); + const thin = thinLine(line, 50); + expect(thin.length).toBeLessThanOrEqual(50); + expect(thin[0]).toBe(0); + expect(thin[thin.length - 1]).toBe(999); + expect(thinLine([1, 2, 3], 50)).toEqual([1, 2, 3]); + }); +}); + +describe('sameHost', () => { + it('compares scheme and host, nothing else', () => { + expect(sameHost('https://w.example.org/api', 'https://w.example.org')).toBe(true); + expect(sameHost('https://w.example.org', 'http://w.example.org')).toBe(false); + expect(sameHost('https://w.example.org', 'https://w.example.org:8443')).toBe(false); + expect(sameHost(null, 'https://w.example.org')).toBe(false); + expect(sameHost('not a url', 'https://w.example.org')).toBe(false); + }); +}); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index c78ff1dbb..b88e8e36a 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from 'vitest/config'; import swc from 'unplugin-swc'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ // SWC transform so NestJS decorator metadata is emitted in tests @@ -196,6 +196,7 @@ export default defineConfig({ 'src/nest/trips/**/*.ts': { statements: 95, branches: 86, functions: 95, lines: 96 }, 'src/nest/unsplash/**/*.ts': { statements: 99, branches: 88, functions: 99, lines: 99 }, 'src/nest/vacay/**/*.ts': { statements: 82, branches: 66, functions: 90, lines: 86 }, + 'src/nest/wanderer/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 }, 'src/nest/weather/**/*.ts': { statements: 93, branches: 78, functions: 91, lines: 97 }, 'src/nest/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 }, @@ -240,21 +241,21 @@ export default defineConfig({ // Node nor Vite can resolve. Point directly at the CJS dist files. // Paths are relative to the monorepo root (packages are hoisted there). '@modelcontextprotocol/sdk/server/mcp': new URL( - '../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js', - import.meta.url + '../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js', + import.meta.url, ).pathname, '@modelcontextprotocol/sdk/server/streamableHttp': new URL( - '../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp.js', - import.meta.url + '../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp.js', + import.meta.url, ).pathname, '@modelcontextprotocol/sdk/inMemory': new URL( - '../node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.js', - import.meta.url + '../node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.js', + import.meta.url, ).pathname, '@modelcontextprotocol/sdk/client/index': new URL( - '../node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js', - import.meta.url + '../node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js', + import.meta.url, ).pathname, }, }, -}); \ No newline at end of file +}); diff --git a/shared/scripts/i18n-untranslated-baseline.json b/shared/scripts/i18n-untranslated-baseline.json index 3f49ebfbf..bc9a60624 100644 --- a/shared/scripts/i18n-untranslated-baseline.json +++ b/shared/scripts/i18n-untranslated-baseline.json @@ -242,7 +242,7 @@ "storage.ts": { "marked": 0, "identical": 4 }, "system_notice.ts": { "marked": 0, "identical": 4 }, "todo.ts": { "marked": 0, "identical": 1 }, - "tours.ts": { "marked": 0, "identical": 3 }, + "tours.ts": { "marked": 0, "identical": 2 }, "transport.ts": { "marked": 0, "identical": 2 }, "trip.ts": { "marked": 0, "identical": 7 }, "undo.ts": { "marked": 0, "identical": 1 }, @@ -352,7 +352,6 @@ }, "ja": { "admin.ts": { "marked": 0, "identical": 9 }, - "budget.ts": { "marked": 0, "identical": 1 }, "common.ts": { "marked": 12, "identical": 0 }, "help.ts": { "marked": 0, "identical": 2 }, "journey.ts": { "marked": 110, "identical": 2 }, diff --git a/shared/src/admin/admin.schema.ts b/shared/src/admin/admin.schema.ts index e31e06e27..64b30499f 100644 --- a/shared/src/admin/admin.schema.ts +++ b/shared/src/admin/admin.schema.ts @@ -54,6 +54,12 @@ export const adminFeatureToggleRequestSchema = z.object({ }); export type AdminFeatureToggleRequest = z.infer; +/** What a feature toggle answers: the flag as it is stored now. */ +export const adminFeatureToggleResponseSchema = z.object({ + enabled: z.boolean(), +}); +export type AdminFeatureToggleResponse = z.infer; + /** * PUT /api/admin/google-quota (#1582): the most Google API calls TREK may make * in one UTC day. Null or 0 removes the ceiling. diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index e14806ba8..0c3c2a066 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -703,6 +703,20 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'تم تحديث الإضافة', 'admin.addons.toast.error': 'فشل تحديث الإضافة', 'admin.addons.group.count': '{enabled} من {total} مُفعّل', + 'admin.addons.subOptions': '{count} خيار', + 'admin.addons.subOptions.zero': 'لا توجد خيارات', + 'admin.addons.subOptions.one': 'خيار واحد', + 'admin.addons.subOptions.two': 'خياران', + 'admin.addons.subOptions.few': '{count} خيارات', + 'admin.addons.subOptions.many': '{count} خيارًا', + 'admin.addons.subOptionsOn': 'المُفعّل: {count}', + 'admin.addons.subOptionsOn.zero': 'لا شيء مُفعّل', + 'admin.addons.subOptionsOn.one': 'واحد مُفعّل', + 'admin.addons.subOptionsOn.two': 'اثنان مُفعّلان', + 'admin.addons.subOptionsOn.few': 'المُفعّل: {count}', + 'admin.addons.subOptionsOn.many': 'المُفعّل: {count}', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': 'يمكن للمستخدمين ربط خادم wanderer الخاص بهم واستيراد دروبه كجولات', 'admin.addons.noAddons': 'لا توجد إضافات متاحة', 'admin.weather.title': 'بيانات الطقس', 'admin.weather.badge': 'منذ 24 مارس 2026', diff --git a/shared/src/i18n/ar/index.ts b/shared/src/i18n/ar/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/ar/index.ts +++ b/shared/src/i18n/ar/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts index 0ad40fccb..fbc833205 100644 --- a/shared/src/i18n/ar/tours.ts +++ b/shared/src/i18n/ar/tours.ts @@ -26,8 +26,70 @@ const tours: TranslationStrings = { 'tours.import.success.two': 'تم استيراد جولتين من GPX', 'tours.import.success.few': 'تم استيراد {count} جولات من GPX', 'tours.import.success.many': 'تم استيراد {count} جولة من GPX', - 'tours.import.noTrack': 'لم يُعثر على أثر مسجّل أو مسار في ملف GPX هذا', - 'tours.import.error': 'تعذّر استيراد ملف GPX', + 'tours.import.noTrackIn': 'لم يُعثر على أثر مسجّل أو مسار في {name}', + 'tours.import.errorIn': 'تعذّر استيراد {name}', + 'tours.import.button': 'استيراد GPX', + 'tours.import.hint': 'استيراد ملفات GPX كجولات في هذه الرحلة', + 'tours.import.unsupported': 'يمكن استيراد ملفات GPX فقط كجولات.', + 'tours.import.skipped': 'تم تخطي {count} أثر مسجّل لأنها موجودة بالفعل في هذه الرحلة', + 'tours.import.skipped.zero': 'لم يتم تخطي أي أثر مسجّل', + 'tours.import.skipped.one': 'تم تخطي أثر مسجّل واحد لأنه موجود بالفعل في هذه الرحلة', + 'tours.import.skipped.two': 'تم تخطي أثرين مسجّلين لأنهما موجودان بالفعل في هذه الرحلة', + 'tours.import.skipped.few': 'تم تخطي {count} آثار مسجّلة لأنها موجودة بالفعل في هذه الرحلة', + 'tours.import.skipped.many': 'تم تخطي {count} أثرًا مسجّلًا لأنها موجودة بالفعل في هذه الرحلة', + 'tours.import.allSkipped': 'كل ما في هذا الملف موجود بالفعل في هذه الرحلة.', + 'tours.import.drop': 'أفلت ملفات GPX لاستيرادها', + 'tours.import.dropHint': 'يصبح كل أثر مسجّل أو مسار في الملف جولة في هذه الرحلة.', + 'tours.import.dropNoPermission': 'لا يمكنك إضافة جولات إلى هذه الرحلة.', + 'tours.import.importing': 'جارٍ استيراد GPX…', + 'tours.days.label': 'أيام الرحلة', + 'tours.days.none': 'ليست مضافة إلى أي يوم من أيام الرحلة بعد.', + 'tours.days.short': 'الأيام {days}', + 'tours.days.short.zero': 'الأيام {days}', + 'tours.days.short.one': 'اليوم {days}', + 'tours.days.short.two': 'اليومان {days}', + 'tours.days.short.few': 'الأيام {days}', + 'tours.days.short.many': 'الأيام {days}', + 'tours.alreadyOnDay': 'هذه الجولة موجودة بالفعل في ذلك اليوم.', + 'tours.conversion.title': 'نسخة قابلة للتعديل', + 'tours.conversion.intro': + 'الاستيراد أثر مسجّل. خطّط نسخة قابلة للتعديل: يأخذ TREK منه نقاط تحكم، ويحسب المسار عبرها من جديد، ويعرض مدى قرب النتيجة منه. لا يتغير شيء حتى تختار.', + 'tours.conversion.replace': 'استبدال الاستيراد', + 'tours.conversion.replaceHint': + 'يعيد تخطيط هذه الجولة في مكانها: تبقى أيامها وملاحظاتها، ويُحتفظ بالأثر المسجّل لتتمكن من العودة إليه.', + 'tours.conversion.adoptHint': 'يخطط جولة جديدة بجانب الاستيراد، الذي يبقى كما هو.', + 'tours.conversion.replacing': 'فُتح في المحرر. احفظه لاستبدال الاستيراد؛ يبقى الأثر المسجّل قابلًا للاستعادة.', + 'tours.recorded.title': 'الأثر المسجّل', + 'tours.recorded.hint': 'أُعيد تخطيط هذه الجولة فوق استيراد. الأثر الذي سُجّلت به محفوظ.', + 'tours.recorded.restore': 'استعادة الأثر المسجّل', + 'tours.recorded.confirmTitle': 'هل تريد استعادة الأثر المسجّل؟', + 'tours.recorded.confirmBody': + 'تُحذف نقاط المرور والخط المحسوب، وتعرض الجولة مجددًا الأثر الذي سُجّلت به. تبقى أيامها وملاحظاتها.', + 'tours.recorded.restored': 'تمت استعادة الأثر المسجّل', + 'tours.recorded.failed': 'تعذّرت استعادة الأثر المسجّل.', + 'tours.conversion.analyze': 'تخطيط نسخة قابلة للتعديل', + 'tours.conversion.analyzing': 'جارٍ حساب المسار عبر {count} نقطة تحكم…', + 'tours.conversion.analyzing.zero': 'جارٍ حساب المسار دون نقاط تحكم…', + 'tours.conversion.analyzing.one': 'جارٍ حساب المسار عبر نقطة تحكم واحدة…', + 'tours.conversion.analyzing.two': 'جارٍ حساب المسار عبر نقطتي تحكم…', + 'tours.conversion.analyzing.few': 'جارٍ حساب المسار عبر {count} نقاط تحكم…', + 'tours.conversion.analyzing.many': 'جارٍ حساب المسار عبر {count} نقطة تحكم…', + 'tours.conversion.failedTrack': 'هذا الأثر المسجّل أقصر من أن تُخطَّط منه جولة.', + 'tours.conversion.failedRouting': 'لم يُعثر على مسار يمر عبر نقاط التحكم.', + 'tours.conversion.controlPoints': 'نقاط التحكم', + 'tours.conversion.withinCorridor': 'ضمن {m} م من الأثر المسجّل', + 'tours.conversion.importLength': 'الاستيراد', + 'tours.conversion.suggestionLength': 'الاقتراح', + 'tours.conversion.maxDeviation': 'أكبر انحراف', + 'tours.conversion.legendImport': 'الاستيراد', + 'tours.conversion.legendSuggestion': 'الاقتراح', + 'tours.conversion.close': 'يتبع الاقتراح الأثر المسجّل عن قرب. لكنه محسوب من جديد، فليس هو الخط نفسه تمامًا.', + 'tours.conversion.different': 'يبتعد الاقتراح عن الأثر المسجّل في مقاطع طويلة. تحقق منه على الخريطة قبل أن تخطط به.', + 'tours.conversion.adopt': 'تخطيط كجولة جديدة', + 'tours.conversion.discard': 'تجاهل', + 'tours.conversion.adopted': 'فُتح كجولة جديدة. احفظه للاحتفاظ به؛ يبقى الاستيراد كما هو.', + 'tours.derivedFrom': 'مخطّطة من {name}', + 'tours.derivedFromHint': 'محسوبة من جديد انطلاقًا من استيراد، لذا قد تختلف عن الأثر المسجّل.', 'tours.delete.confirmBody': 'هل تريد حذف هذه الجولة نهائيًا؟ ستُحذف مساراتها ونقاطها القابلة للتحرير وتعييناتها للأيام. لا يمكن التراجع عن هذا الإجراء.', 'tours.delete.bulkConfirmBody': @@ -41,9 +103,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'أعلى ارتفاع', 'tours.detail.ascent': 'إجمالي الصعود', 'tours.detail.descent': 'إجمالي الهبوط', - 'tours.detail.difficulty': 'مستوى الصعوبة', 'tours.detail.source': 'المصدر', - 'tours.detail.gpxReadOnly': 'جولات GPX متاحة للعرض فقط، ولا يمكن تعديلها في أداة التخطيط بعد.', 'tours.detail.navigationHint': 'تفتح الملاحة نقطة بداية الدرب، ولا توفّر إرشادًا على طول مسار الجولة.', 'tours.planner.title': 'تخطيط جولة', 'tours.planner.newTour': 'تخطيط جولة جديدة', diff --git a/shared/src/i18n/ar/wanderer.ts b/shared/src/i18n/ar/wanderer.ts new file mode 100644 index 000000000..3169ab3a2 --- /dev/null +++ b/shared/src/i18n/ar/wanderer.ts @@ -0,0 +1,100 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'متصل', + 'wanderer.notConnected': 'غير متصل', + 'wanderer.intro': + 'اربط خادم wanderer الخاص بك لاستيراد دروبه كجولات. يبقى الرمز المميز على الخادم ولن يُعرض مرة أخرى.', + 'wanderer.url': 'رابط الخادم', + 'wanderer.token': 'رمز API', + 'wanderer.tokenHint': 'في wanderer ضمن الإعدادات، ثم الحساب، ثم رموز API.', + 'wanderer.tokenInvalid': 'هذا ليس رمز API لـ wanderer. يبدأ الرمز بـ wanderer_key_.', + 'wanderer.allowInsecureTls': 'السماح بشهادة موقّعة ذاتيًا', + 'wanderer.allowInsecureTlsHint': 'فقط لخادم على شبكتك الخاصة.', + 'wanderer.test.button': 'اختبار الاتصال', + 'wanderer.test.ok': 'تم الاتصال.', + 'wanderer.test.okCount': 'تم الاتصال. يمكنك استيراد {count} درب من دروبك.', + 'wanderer.test.okCount.zero': 'تم الاتصال. لا توجد دروب يمكنك استيرادها.', + 'wanderer.test.okCount.one': 'تم الاتصال. يمكنك استيراد درب واحد من دروبك.', + 'wanderer.test.okCount.two': 'تم الاتصال. يمكنك استيراد دربين من دروبك.', + 'wanderer.test.okCount.few': 'تم الاتصال. يمكنك استيراد {count} دروب من دروبك.', + 'wanderer.test.okCount.many': 'تم الاتصال. يمكنك استيراد {count} دربًا من دروبك.', + 'wanderer.disconnect': 'قطع الاتصال', + 'wanderer.saved': 'تم حفظ اتصال wanderer', + 'wanderer.disconnected': 'تمت إزالة اتصال wanderer', + 'wanderer.loadFailed': 'تعذّر تحميل اتصال wanderer', + 'wanderer.privateIp': + 'هذا العنوان ضمن شبكة خاصة. إذا تعذّر على TREK الوصول إليه، فعلى المسؤول السماح بالشبكات الداخلية.', + 'wanderer.error.disabled': 'لم يفعّل المسؤول wanderer للجولات.', + 'wanderer.error.not_connected': 'لم يتم ربط wanderer بعد.', + 'wanderer.error.invalid_url': 'هذا العنوان ليس رابط wanderer صالحًا.', + 'wanderer.error.blocked_address': + 'لا يُسمح لـ TREK بالوصول إلى هذا العنوان. لخادم على شبكتك الخاصة، يجب على المسؤول ضبط ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': 'تعذّر الوصول إلى wanderer. تحقق من العنوان ومن أن الخادم يعمل.', + 'wanderer.error.tls': 'تعذّر التحقق من شهادة الخادم. إذا كانت موقّعة ذاتيًا، فاسمح بها في الأسفل.', + 'wanderer.error.unauthorized': 'لم يقبل wanderer رمز API. ربما أُلغي أو انتهت صلاحيته.', + 'wanderer.error.forbidden': 'رفض wanderer الوصول إلى هذا الدرب.', + 'wanderer.error.not_found': 'لم يعد هذا الدرب موجودًا في wanderer.', + 'wanderer.error.not_own_trail': 'يمكن استيراد دروبك الخاصة في wanderer فقط.', + 'wanderer.error.invalid_response': 'لم يأتِ الرد من wanderer. تحقق من أن العنوان يشير إلى wanderer نفسه.', + 'wanderer.error.too_large': 'ملف الدرب أكبر من 10 MB.', + 'wanderer.error.server_error': 'ردّ wanderer بخطأ. حاول مرة أخرى لاحقًا.', + 'wanderer.error.offline': 'أنت غير متصل بالإنترنت. لا يمكن البحث في wanderer إلا عند الاتصال.', + 'wanderer.error.preview_stale': 'تغيّر الدرب في wanderer أثناء عرضك له.', + 'wanderer.error.already_imported': 'هذا الدرب موجود بالفعل في الرحلة.', + 'wanderer.error.no_track': 'لا يحتوي هذا الدرب على أثر مسجّل أو مسار للاستيراد.', + 'wanderer.error.unknown': 'حدث خطأ ما في wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'دروبك', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'استيراد درب من خادم wanderer الخاص بك', + 'wanderer.browser.backToList': 'العودة إلى النتائج', + 'wanderer.browser.searchPlaceholder': 'البحث في الدروب', + 'wanderer.browser.areaOff': 'البحث في منطقة الخريطة هذه', + 'wanderer.browser.areaOn': 'جارٍ البحث في منطقة الخريطة هذه', + 'wanderer.browser.areaOnHint': 'الدروب التي تمتد إلى الجزء الظاهر من الخريطة فقط', + 'wanderer.browser.areaOffHint': 'البحث في كل مكان مجددًا', + 'wanderer.browser.connectTitle': 'ربط wanderer', + 'wanderer.browser.connect': 'الربط من الإعدادات', + 'wanderer.browser.empty': 'لم يُعثر على دروب', + 'wanderer.browser.emptyArea': 'لا توجد دروب في منطقة الخريطة هذه', + 'wanderer.browser.emptyHint': 'جرّب كلمة أخرى أو حرّك الخريطة.', + 'wanderer.browser.count': '{count} درب', + 'wanderer.browser.count.zero': 'لا توجد دروب', + 'wanderer.browser.count.one': 'درب واحد', + 'wanderer.browser.count.two': 'دربان', + 'wanderer.browser.count.few': '{count} دروب', + 'wanderer.browser.count.many': '{count} دربًا', + 'wanderer.browser.more': 'تحميل المزيد', + 'wanderer.browser.public': 'عام', + 'wanderer.browser.private': 'خاص', + 'wanderer.browser.publicHint': 'عام في wanderer: يمكن لكل من على خادمك رؤيته هناك', + 'wanderer.browser.privateHint': 'خاص في wanderer: أنت وحدك من يمكنه رؤيته هناك', + 'wanderer.browser.inTrip': 'في هذه الرحلة', + 'wanderer.browser.inTripHint': 'تم استيراده بالفعل إلى هذه الرحلة', + 'wanderer.browser.areaBadge': 'منطقة الخريطة', + 'wanderer.preview.loading': 'جارٍ قراءة الدرب من wanderer…', + 'wanderer.preview.measured': 'من ملف GPX', + 'wanderer.preview.points': 'نقاط الأثر', + 'wanderer.preview.noElevation': 'لا يحتوي الملف على بيانات ارتفاع، لذا فإن الصعود والهبوط غير معروفين.', + 'wanderer.preview.openExternal': 'فتح في wanderer', + 'wanderer.preview.import': 'استيراد إلى الرحلة', + 'wanderer.preview.importNewVersion': 'استيراد كنسخة جديدة', + 'wanderer.preview.openSnapshot': 'فتح الجولة', + 'wanderer.preview.openSnapshotShort': 'فتح الجولة في هذه الرحلة', + 'wanderer.preview.newHint': + 'ينسخ الاستيراد الدرب إلى هذه الرحلة كجولة مستقلة. التغييرات اللاحقة في wanderer لا تؤثر فيها.', + 'wanderer.preview.unchanged': + 'موجود بالفعل في هذه الرحلة باسم «{name}»، استُورد في {date}، ولم يتغيّر في wanderer منذ ذلك الحين.', + 'wanderer.preview.changed': 'استُورد في {date} باسم «{name}». تغيّر في wanderer منذ ذلك الحين: {parts}.', + 'wanderer.preview.editedLocally': 'عُدّلت الجولة في هذه الرحلة هنا منذ ذلك الحين.', + 'wanderer.preview.newVersionHint': 'يضيف الاستيراد مجددًا نسخة جديدة بجانبها. تبقى الجولة في الرحلة كما هي.', + 'wanderer.preview.change.name': 'الاسم', + 'wanderer.preview.change.description': 'الوصف', + 'wanderer.preview.change.route': 'المسار', + 'wanderer.tourTitle': 'جولة wanderer', + 'wanderer.import.done': 'تم استيراد «{name}»', + 'wanderer.import.stale': 'تغيّر الدرب في wanderer. هذه هي النسخة الحالية.', +}; +export default wanderer; diff --git a/shared/src/i18n/az/admin.ts b/shared/src/i18n/az/admin.ts index d64885c1e..60af7aa3b 100644 --- a/shared/src/i18n/az/admin.ts +++ b/shared/src/i18n/az/admin.ts @@ -743,6 +743,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Əlavə yeniləndi', 'admin.addons.toast.error': 'Əlavəni yeniləmək mümkün olmadı', 'admin.addons.group.count': '{total} əlavədən {enabled} aktivdir', + 'admin.addons.subOptions': '{count} seçim', + 'admin.addons.subOptions.one': '{count} seçim', + 'admin.addons.subOptionsOn': '{count} aktiv', + 'admin.addons.subOptionsOn.one': '{count} aktiv', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'İstifadəçilər öz wanderer instansiyalarını qoşa və onun cığırlarını tur kimi idxal edə bilərlər', 'admin.addons.noAddons': 'Heç bir əlavə mövcud deyil', 'admin.weather.title': 'Hava məlumatları', 'admin.weather.badge': '24 mart 2026-cı ildən', diff --git a/shared/src/i18n/az/index.ts b/shared/src/i18n/az/index.ts index 49ed7a462..ec191b6db 100644 --- a/shared/src/i18n/az/index.ts +++ b/shared/src/i18n/az/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -70,6 +71,7 @@ const locale = { ...atlas, ...trip, ...tours, + ...wanderer, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts index 5fa68ef51..b128eca46 100644 --- a/shared/src/i18n/az/tours.ts +++ b/shared/src/i18n/az/tours.ts @@ -22,8 +22,59 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Ehtiyatla idxal edilib — yüksəklik və göstəricilər natamamdır', 'tours.import.success': 'GPX-dən {count} tur idxal edildi', 'tours.import.success.one': 'GPX-dən {count} tur idxal edildi', - 'tours.import.noTrack': 'Bu GPX faylında iz və ya marşrut tapılmadı', - 'tours.import.error': 'GPX faylını idxal etmək mümkün olmadı', + 'tours.import.noTrackIn': '{name} daxilində iz və ya marşrut tapılmadı', + 'tours.import.errorIn': '{name} faylını idxal etmək mümkün olmadı', + 'tours.import.button': 'GPX idxal et', + 'tours.import.hint': 'GPX fayllarını bu səyahətin turları kimi idxal edin', + 'tours.import.unsupported': 'Tur kimi yalnız GPX faylları idxal edilə bilər.', + 'tours.import.skipped': '{count} iz artıq bu səyahətdə idi və ötürüldü', + 'tours.import.skipped.one': '{count} iz artıq bu səyahətdə idi və ötürüldü', + 'tours.import.allSkipped': 'Həmin fayldakı hər şey artıq bu səyahətdədir.', + 'tours.import.drop': 'İdxal etmək üçün GPX fayllarını buraya atın', + 'tours.import.dropHint': 'Fayldakı hər iz və ya marşrut bu səyahətin turuna çevrilir.', + 'tours.import.dropNoPermission': 'Bu səyahətə tur əlavə edə bilməzsiniz.', + 'tours.import.importing': 'GPX idxal edilir…', + 'tours.days.label': 'Səyahət günləri', + 'tours.days.none': 'Hələ səyahətin heç bir gününə əlavə edilməyib.', + 'tours.days.short': 'Günlər {days}', + 'tours.days.short.one': 'Gün {days}', + 'tours.alreadyOnDay': 'Bu tur artıq həmin gündədir.', + 'tours.conversion.title': 'Redaktə edilə bilən versiya', + 'tours.conversion.intro': + 'İdxal qeydə alınmış izdir. Redaktə edilə bilən versiya planlaşdırın: TREK ondan nəzarət nöqtələri götürür, onlar üzərindən marşrutu yenidən hesablayır və nəticənin nə qədər yaxın olduğunu göstərir. Siz seçənə qədər heç nə dəyişmir.', + 'tours.conversion.replace': 'İdxalı əvəz et', + 'tours.conversion.replaceHint': + 'Bu turu yerindəcə yenidən planlaşdırır: günləri və qeydləri qalır, qeydə alınmış iz isə saxlanılır ki, ona qayıda biləsiniz.', + 'tours.conversion.adoptHint': 'İdxalın yanında yeni tur planlaşdırır, idxal olduğu kimi qalır.', + 'tours.conversion.replacing': + 'Redaktorda açıldı. İdxalı əvəz etmək üçün onu yadda saxlayın; qeydə alınmış iz bərpa edilə bilən qalır.', + 'tours.recorded.title': 'Qeydə alınmış iz', + 'tours.recorded.hint': 'Bu tur idxal əsasında yenidən planlaşdırılıb. Onun qeydə alındığı iz saxlanılır.', + 'tours.recorded.restore': 'Qeydə alınmış izi bərpa et', + 'tours.recorded.confirmTitle': 'Qeydə alınmış iz bərpa edilsin?', + 'tours.recorded.confirmBody': + 'Yol nöqtələri və hesablanmış xətt silinir, tur yenidən qeydə alındığı izi göstərir. Günləri və qeydləri qalır.', + 'tours.recorded.restored': 'Qeydə alınmış iz bərpa edildi', + 'tours.recorded.failed': 'Qeydə alınmış izi bərpa etmək mümkün olmadı.', + 'tours.conversion.analyze': 'Redaktə edilə bilən versiya planlaşdır', + 'tours.conversion.analyzing': '{count} nəzarət nöqtəsi üzərindən marşrut hesablanır…', + 'tours.conversion.analyzing.one': '{count} nəzarət nöqtəsi üzərindən marşrut hesablanır…', + 'tours.conversion.failedTrack': 'Bu iz tur planlaşdırmaq üçün çox qısadır.', + 'tours.conversion.failedRouting': 'Nəzarət nöqtələri üzərindən marşrut tapılmadı.', + 'tours.conversion.controlPoints': 'Nəzarət nöqtələri', + 'tours.conversion.withinCorridor': 'İzdən {m} m məsafədə', + 'tours.conversion.importLength': 'İdxal', + 'tours.conversion.suggestionLength': 'Təklif', + 'tours.conversion.maxDeviation': 'Ən böyük kənarlaşma', + 'tours.conversion.legendImport': 'İdxal', + 'tours.conversion.legendSuggestion': 'Təklif', + 'tours.conversion.close': 'Təklif izi yaxından izləyir. Marşrut yenidən hesablandığı üçün eyni xətt deyil.', + 'tours.conversion.different': 'Təklif uzun hissələrdə izdən ayrılır. Onunla planlaşdırmadan əvvəl xəritədə yoxlayın.', + 'tours.conversion.adopt': 'Yeni tur kimi planlaşdır', + 'tours.conversion.discard': 'Ləğv et', + 'tours.conversion.adopted': 'Yeni tur kimi açıldı. Qalması üçün onu yadda saxlayın; idxal olduğu kimi qalır.', + 'tours.derivedFrom': '{name} əsasında planlaşdırılıb', + 'tours.derivedFromHint': 'İdxaldan yenidən hesablanıb, ona görə yazılmış izdən fərqlənə bilər.', 'tours.delete.confirmBody': 'Bu tur həmişəlik silinsin? Onun marşrutu, redaktə edilə bilən nöqtələri və gün təyinatları silinəcək. Bu əməliyyatı geri qaytarmaq mümkün deyil.', 'tours.delete.bulkConfirmBody': @@ -38,9 +89,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Maksimum yüksəklik', 'tours.detail.ascent': 'Yüksəlmə', 'tours.detail.descent': 'Enmə', - 'tours.detail.difficulty': 'Çətinlik', 'tours.detail.source': 'Mənbə', - 'tours.detail.gpxReadOnly': 'GPX turlarına yalnız baxmaq olar; onları planlaşdırıcıda hələ redaktə etmək olmur.', 'tours.detail.navigationHint': 'Naviqasiya cığırın başlanğıcını açır; marşrut boyunca istiqamət göstərmir.', 'tours.planner.title': 'Tur planlaşdır', 'tours.planner.newTour': 'Yeni tur planlaşdır', diff --git a/shared/src/i18n/az/wanderer.ts b/shared/src/i18n/az/wanderer.ts new file mode 100644 index 000000000..a882e6b46 --- /dev/null +++ b/shared/src/i18n/az/wanderer.ts @@ -0,0 +1,94 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Qoşulub', + 'wanderer.notConnected': 'Qoşulmayıb', + 'wanderer.intro': + 'Cığırlarını tur kimi idxal etmək üçün öz wanderer instansiyanızı qoşun. Token serverdə qalır və bir daha göstərilmir.', + 'wanderer.url': 'İnstansiya URL-i', + 'wanderer.token': 'API tokeni', + 'wanderer.tokenHint': 'wanderer-də Parametrlər, Hesab, API tokenləri bölməsində.', + 'wanderer.tokenInvalid': 'Bu wanderer API tokeni deyil. Token wanderer_key_ ilə başlayır.', + 'wanderer.allowInsecureTls': 'Özü tərəfindən imzalanmış sertifikata icazə ver', + 'wanderer.allowInsecureTlsHint': 'Yalnız öz şəbəkənizdəki instansiya üçün.', + 'wanderer.test.button': 'Bağlantını yoxla', + 'wanderer.test.ok': 'Qoşuldu.', + 'wanderer.test.okCount': 'Qoşuldu. {count} cığırınızı idxal edə bilərsiniz.', + 'wanderer.test.okCount.one': 'Qoşuldu. {count} cığırınızı idxal edə bilərsiniz.', + 'wanderer.disconnect': 'Bağlantını kəs', + 'wanderer.saved': 'wanderer bağlantısı yadda saxlanıldı', + 'wanderer.disconnected': 'wanderer bağlantısı silindi', + 'wanderer.loadFailed': 'wanderer bağlantısını yükləmək mümkün olmadı', + 'wanderer.privateIp': + 'Bu ünvan özəl şəbəkədədir. TREK ona çata bilmirsə, administrator daxili şəbəkələrə icazə verməlidir.', + 'wanderer.error.disabled': 'Administratorunuz turlar üçün wanderer-i aktivləşdirməyib.', + 'wanderer.error.not_connected': 'wanderer hələ qoşulmayıb.', + 'wanderer.error.invalid_url': 'Bu ünvan etibarlı wanderer URL-i deyil.', + 'wanderer.error.blocked_address': + 'TREK bu ünvana qoşula bilməz. Öz şəbəkənizdəki instansiya üçün administrator ALLOW_INTERNAL_NETWORK=true təyin etməlidir.', + 'wanderer.error.unreachable': 'wanderer-ə çatmaq mümkün olmadı. Ünvanı və instansiyanın işlədiyini yoxlayın.', + 'wanderer.error.tls': + 'İnstansiyanın sertifikatını yoxlamaq mümkün olmadı. Özü tərəfindən imzalanmış sertifikat üçün aşağıda icazə verin.', + 'wanderer.error.unauthorized': 'wanderer API tokenini qəbul etmədi. O, ləğv edilmiş və ya müddəti bitmiş ola bilər.', + 'wanderer.error.forbidden': 'wanderer bu cığıra girişi rədd etdi.', + 'wanderer.error.not_found': 'Bu cığır artıq wanderer-də yoxdur.', + 'wanderer.error.not_own_trail': 'Yalnız öz wanderer cığırlarınızı idxal edə bilərsiniz.', + 'wanderer.error.invalid_response': 'Cavab wanderer-dən gəlməyib. Ünvanın birbaşa wanderer-ə yönəldiyini yoxlayın.', + 'wanderer.error.too_large': 'Cığır faylı 10 MB-dan böyükdür.', + 'wanderer.error.server_error': 'wanderer xəta ilə cavab verdi. Sonra yenidən cəhd edin.', + 'wanderer.error.offline': 'Oflaynsınız. wanderer-də yalnız onlayn axtarış etmək olar.', + 'wanderer.error.preview_stale': 'Siz baxarkən cığır wanderer-də dəyişdi.', + 'wanderer.error.already_imported': 'Bu cığır artıq səyahətdədir.', + 'wanderer.error.no_track': 'Bu cığırda idxal ediləcək iz və ya marşrut yoxdur.', + 'wanderer.error.unknown': 'wanderer ilə nəsə səhv getdi.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Cığırlarınız', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'wanderer instansiyanızdan cığır idxal edin', + 'wanderer.browser.backToList': 'Nəticələrə qayıt', + 'wanderer.browser.searchPlaceholder': 'Cığırları axtar', + 'wanderer.browser.areaOff': 'Bu xəritə sahəsində axtar', + 'wanderer.browser.areaOn': 'Bu xəritə sahəsində axtarılır', + 'wanderer.browser.areaOnHint': 'Yalnız xəritənin gördüyünüz hissəsinə çatan cığırlar', + 'wanderer.browser.areaOffHint': 'Yenidən hər yerdə axtar', + 'wanderer.browser.connectTitle': 'wanderer-i qoş', + 'wanderer.browser.connect': 'Parametrlərdə qoş', + 'wanderer.browser.empty': 'Cığır tapılmadı', + 'wanderer.browser.emptyArea': 'Bu xəritə sahəsində cığır yoxdur', + 'wanderer.browser.emptyHint': 'Başqa söz yoxlayın və ya xəritəni sürüşdürün.', + 'wanderer.browser.count': '{count} cığır', + 'wanderer.browser.count.one': '{count} cığır', + 'wanderer.browser.more': 'Daha çox yüklə', + 'wanderer.browser.public': 'İctimai', + 'wanderer.browser.private': 'Şəxsi', + 'wanderer.browser.publicHint': 'wanderer-də ictimai: instansiyanızdakı hər kəs onu orada görə bilər', + 'wanderer.browser.privateHint': 'wanderer-də şəxsi: onu orada yalnız siz görə bilərsiniz', + 'wanderer.browser.inTrip': 'Bu səyahətdə', + 'wanderer.browser.inTripHint': 'Artıq bu səyahətə idxal edilib', + 'wanderer.browser.areaBadge': 'Xəritə sahəsi', + 'wanderer.preview.loading': 'Cığır wanderer-dən oxunur…', + 'wanderer.preview.measured': 'GPX faylından', + 'wanderer.preview.points': 'İz nöqtələri', + 'wanderer.preview.noElevation': 'Faylda hündürlük məlumatı yoxdur, ona görə qalxma və enmə məlum deyil.', + 'wanderer.preview.openExternal': 'wanderer-də aç', + 'wanderer.preview.import': 'Səyahətə idxal et', + 'wanderer.preview.importNewVersion': 'Yeni versiya kimi idxal et', + 'wanderer.preview.openSnapshot': 'Turu aç', + 'wanderer.preview.openSnapshotShort': 'Turu bu səyahətdə aç', + 'wanderer.preview.newHint': + 'İdxal cığırı bu səyahətə ayrıca tur kimi kopyalayır. wanderer-dəki sonrakı dəyişikliklər ona təsir etmir.', + 'wanderer.preview.unchanged': + 'Artıq bu səyahətdə “{name}” adı ilə var, {date} tarixində idxal edilib və o vaxtdan wanderer-də dəyişməyib.', + 'wanderer.preview.changed': '{date} tarixində “{name}” adı ilə idxal edilib. O vaxtdan wanderer-də dəyişib: {parts}.', + 'wanderer.preview.editedLocally': 'Bu səyahətdəki tur o vaxtdan burada redaktə edilib.', + 'wanderer.preview.newVersionHint': + 'Yenidən idxal onun yanına yeni versiya əlavə edir. Səyahətdəki tur olduğu kimi qalır.', + 'wanderer.preview.change.name': 'ad', + 'wanderer.preview.change.description': 'təsvir', + 'wanderer.preview.change.route': 'marşrut', + 'wanderer.tourTitle': 'wanderer turu', + 'wanderer.import.done': '“{name}” idxal edildi', + 'wanderer.import.stale': 'Cığır wanderer-də dəyişib. Budur cari versiya.', +}; +export default wanderer; diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 57691dea7..200d61bdd 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -651,6 +651,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Complemento atualizado', 'admin.addons.toast.error': 'Falha ao atualizar complemento', 'admin.addons.group.count': '{enabled} de {total} ativados', + 'admin.addons.subOptions': '{count} opções', + 'admin.addons.subOptions.one': '{count} opção', + 'admin.addons.subOptionsOn': '{count} ativas', + 'admin.addons.subOptionsOn.one': '{count} ativa', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Os usuários podem conectar a própria instância do wanderer e importar as trilhas dela como passeios', 'admin.addons.noAddons': 'Nenhum complemento disponível', 'admin.addons.integrationHint': 'Serviços de backend e integrações de API sem página dedicada', 'admin.weather.title': 'Dados meteorológicos', diff --git a/shared/src/i18n/br/index.ts b/shared/src/i18n/br/index.ts index 29eb22621..abeef31ea 100644 --- a/shared/src/i18n/br/index.ts +++ b/shared/src/i18n/br/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts index 90b5a176c..4cdc87072 100644 --- a/shared/src/i18n/br/tours.ts +++ b/shared/src/i18n/br/tours.ts @@ -22,8 +22,63 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Importado com ressalvas: elevação ou métricas incompletas', 'tours.import.success': '{count} passeios importados de GPX', 'tours.import.success.one': '{count} passeio importado de GPX', - 'tours.import.noTrack': 'Nenhum traçado ou rota encontrado nesse arquivo GPX', - 'tours.import.error': 'Não foi possível importar o arquivo GPX', + 'tours.import.noTrackIn': 'Nenhum traçado ou rota encontrado em {name}', + 'tours.import.errorIn': 'Não foi possível importar {name}', + 'tours.import.button': 'Importar GPX', + 'tours.import.hint': 'Importe arquivos GPX como passeios desta viagem', + 'tours.import.unsupported': 'Só arquivos GPX podem ser importados como passeios.', + 'tours.import.skipped': '{count} traçados já estavam nesta viagem e foram ignorados', + 'tours.import.skipped.one': '{count} traçado já estava nesta viagem e foi ignorado', + 'tours.import.allSkipped': 'Tudo o que há nesse arquivo já está nesta viagem.', + 'tours.import.drop': 'Solte arquivos GPX para importá-los', + 'tours.import.dropHint': 'Cada traçado ou rota de um arquivo vira um passeio desta viagem.', + 'tours.import.dropNoPermission': 'Você não pode adicionar passeios a esta viagem.', + 'tours.import.importing': 'Importando GPX…', + 'tours.days.label': 'Dias da viagem', + 'tours.days.none': 'Ainda não está em nenhum dia da viagem.', + 'tours.days.short': 'Dias {days}', + 'tours.days.short.one': 'Dia {days}', + 'tours.alreadyOnDay': 'Este passeio já está nesse dia.', + 'tours.conversion.title': 'Versão editável', + 'tours.conversion.intro': + 'Uma importação é um traçado gravado. Planeje uma versão editável: o TREK extrai pontos de controle dele, recalcula a rota por eles e mostra o quanto o resultado se aproxima. Nada muda até você escolher.', + 'tours.conversion.replace': 'Substituir a importação', + 'tours.conversion.replaceHint': + 'Planeja este passeio de novo no mesmo lugar: os dias e as notas permanecem, e o traçado gravado é guardado para que você possa voltar a ele.', + 'tours.conversion.adoptHint': 'Planeja um novo passeio ao lado da importação, que continua como está.', + 'tours.conversion.replacing': + 'Aberto no editor. Salve para substituir a importação; o traçado gravado continua restaurável.', + 'tours.recorded.title': 'Traçado gravado', + 'tours.recorded.hint': + 'Este passeio foi planejado de novo sobre uma importação. O traçado com que ele foi gravado está guardado.', + 'tours.recorded.restore': 'Restaurar traçado gravado', + 'tours.recorded.confirmTitle': 'Restaurar o traçado gravado?', + 'tours.recorded.confirmBody': + 'Os pontos de passagem e a linha calculada são descartados, e o passeio volta a mostrar o traçado com que foi gravado. Os dias e as notas permanecem.', + 'tours.recorded.restored': 'Traçado gravado restaurado', + 'tours.recorded.failed': 'Não foi possível restaurar o traçado gravado.', + 'tours.conversion.analyze': 'Planejar uma versão editável', + 'tours.conversion.analyzing': 'Calculando a rota por {count} pontos de controle…', + 'tours.conversion.analyzing.one': 'Calculando a rota por {count} ponto de controle…', + 'tours.conversion.failedTrack': 'Este traçado é curto demais para planejar um passeio a partir dele.', + 'tours.conversion.failedRouting': 'Nenhuma rota foi encontrada pelos pontos de controle.', + 'tours.conversion.controlPoints': 'Pontos de controle', + 'tours.conversion.withinCorridor': 'A até {m} m do traçado', + 'tours.conversion.importLength': 'Importação', + 'tours.conversion.suggestionLength': 'Sugestão', + 'tours.conversion.maxDeviation': 'Maior desvio', + 'tours.conversion.legendImport': 'Importação', + 'tours.conversion.legendSuggestion': 'Sugestão', + 'tours.conversion.close': + 'A sugestão acompanha o traçado de perto. A rota foi recalculada, então não é a mesma linha.', + 'tours.conversion.different': + 'A sugestão se afasta do traçado em trechos longos. Confira no mapa antes de planejar com ela.', + 'tours.conversion.adopt': 'Planejar como novo passeio', + 'tours.conversion.discard': 'Descartar', + 'tours.conversion.adopted': 'Aberto como novo passeio. Salve para mantê-lo; a importação continua como está.', + 'tours.derivedFrom': 'Planejado a partir de {name}', + 'tours.derivedFromHint': + 'Rota recalculada a partir de uma importação, por isso pode ser diferente do traçado gravado.', 'tours.delete.confirmBody': 'Excluir este passeio permanentemente? A rota, os pontos editáveis e as atribuições aos dias serão removidos. Esta ação não pode ser desfeita.', 'tours.delete.bulkConfirmBody': @@ -38,10 +93,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Altitude máxima', 'tours.detail.ascent': 'Ganho de elevação', 'tours.detail.descent': 'Perda de elevação', - 'tours.detail.difficulty': 'Dificuldade', 'tours.detail.source': 'Origem', - 'tours.detail.gpxReadOnly': - 'Os passeios GPX são somente para visualização e ainda não podem ser editados no planejador.', 'tours.detail.navigationHint': 'A navegação abre o início da trilha; ela não orienta ao longo da rota.', 'tours.planner.title': 'Planejar um passeio', 'tours.planner.newTour': 'Planejar novo passeio', diff --git a/shared/src/i18n/br/wanderer.ts b/shared/src/i18n/br/wanderer.ts new file mode 100644 index 000000000..444e4654a --- /dev/null +++ b/shared/src/i18n/br/wanderer.ts @@ -0,0 +1,95 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Conectado', + 'wanderer.notConnected': 'Não conectado', + 'wanderer.intro': + 'Conecte sua própria instância do wanderer para importar as trilhas dela como passeios. O token fica no servidor e nunca mais é exibido.', + 'wanderer.url': 'Endereço da instância', + 'wanderer.token': 'Token de API', + 'wanderer.tokenHint': 'No wanderer, em Configurações, Conta, Tokens de API.', + 'wanderer.tokenInvalid': 'Isso não é um token de API do wanderer. Um token começa com wanderer_key_.', + 'wanderer.allowInsecureTls': 'Permitir certificado autoassinado', + 'wanderer.allowInsecureTlsHint': 'Só para uma instância na sua própria rede.', + 'wanderer.test.button': 'Testar conexão', + 'wanderer.test.ok': 'Conectado.', + 'wanderer.test.okCount': 'Conectado. Você pode importar {count} das suas trilhas.', + 'wanderer.test.okCount.one': 'Conectado. Você pode importar {count} das suas trilhas.', + 'wanderer.disconnect': 'Desconectar', + 'wanderer.saved': 'Conexão com o wanderer salva', + 'wanderer.disconnected': 'Conexão com o wanderer removida', + 'wanderer.loadFailed': 'Não foi possível carregar a conexão com o wanderer', + 'wanderer.privateIp': + 'Este endereço está numa rede privada. Se o TREK não conseguir alcançá-lo, o administrador precisa permitir redes internas.', + 'wanderer.error.disabled': 'Seu administrador não ativou o wanderer para os passeios.', + 'wanderer.error.not_connected': 'O wanderer ainda não está conectado.', + 'wanderer.error.invalid_url': 'Esse endereço não é uma URL válida do wanderer.', + 'wanderer.error.blocked_address': + 'O TREK não pode acessar esse endereço. Para uma instância na sua própria rede, o administrador precisa definir ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': + 'Não foi possível alcançar o wanderer. Confira o endereço e se a instância está rodando.', + 'wanderer.error.tls': 'Não foi possível verificar o certificado da instância. Se for autoassinado, permita abaixo.', + 'wanderer.error.unauthorized': 'O wanderer não aceitou o token de API. Ele pode ter sido revogado ou ter expirado.', + 'wanderer.error.forbidden': 'O wanderer recusou o acesso a esta trilha.', + 'wanderer.error.not_found': 'Esta trilha não existe mais no wanderer.', + 'wanderer.error.not_own_trail': 'Só suas próprias trilhas do wanderer podem ser importadas.', + 'wanderer.error.invalid_response': + 'A resposta não veio do wanderer. Confira se o endereço aponta para o próprio wanderer.', + 'wanderer.error.too_large': 'O arquivo da trilha tem mais de 10 MB.', + 'wanderer.error.server_error': 'O wanderer respondeu com um erro. Tente de novo mais tarde.', + 'wanderer.error.offline': 'Você está offline. O wanderer só pode ser pesquisado online.', + 'wanderer.error.preview_stale': 'A trilha mudou no wanderer enquanto você a olhava.', + 'wanderer.error.already_imported': 'Esta trilha já está na viagem.', + 'wanderer.error.no_track': 'Esta trilha não tem traçado nem rota para importar.', + 'wanderer.error.unknown': 'Algo deu errado com o wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Suas trilhas', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Importe uma trilha da sua instância do wanderer', + 'wanderer.browser.backToList': 'Voltar aos resultados', + 'wanderer.browser.searchPlaceholder': 'Pesquisar trilhas', + 'wanderer.browser.areaOff': 'Pesquisar nesta área do mapa', + 'wanderer.browser.areaOn': 'Pesquisando nesta área do mapa', + 'wanderer.browser.areaOnHint': 'Só trilhas que entram na parte do mapa que você está vendo', + 'wanderer.browser.areaOffHint': 'Pesquisar em todo lugar de novo', + 'wanderer.browser.connectTitle': 'Conectar o wanderer', + 'wanderer.browser.connect': 'Conectar nas configurações', + 'wanderer.browser.empty': 'Nenhuma trilha encontrada', + 'wanderer.browser.emptyArea': 'Nenhuma trilha nesta área do mapa', + 'wanderer.browser.emptyHint': 'Tente outra palavra ou mova o mapa.', + 'wanderer.browser.count': '{count} trilhas', + 'wanderer.browser.count.one': '{count} trilha', + 'wanderer.browser.more': 'Carregar mais', + 'wanderer.browser.public': 'Pública', + 'wanderer.browser.private': 'Privada', + 'wanderer.browser.publicHint': 'Pública no wanderer: todos na sua instância podem vê-la lá', + 'wanderer.browser.privateHint': 'Privada no wanderer: só você pode vê-la lá', + 'wanderer.browser.inTrip': 'Nesta viagem', + 'wanderer.browser.inTripHint': 'Já importada nesta viagem', + 'wanderer.browser.areaBadge': 'Área do mapa', + 'wanderer.preview.loading': 'Lendo a trilha do wanderer…', + 'wanderer.preview.measured': 'Do arquivo GPX', + 'wanderer.preview.points': 'Pontos do traçado', + 'wanderer.preview.noElevation': 'O arquivo não tem elevação, então a subida e a descida são desconhecidas.', + 'wanderer.preview.openExternal': 'Abrir no wanderer', + 'wanderer.preview.import': 'Importar para a viagem', + 'wanderer.preview.importNewVersion': 'Importar como nova versão', + 'wanderer.preview.openSnapshot': 'Abrir o passeio', + 'wanderer.preview.openSnapshotShort': 'Abrir o passeio nesta viagem', + 'wanderer.preview.newHint': + 'A importação copia a trilha para esta viagem como um passeio próprio. Alterações posteriores no wanderer não mexem nele.', + 'wanderer.preview.unchanged': + 'Já está nesta viagem como “{name}”, importada em {date} e sem alterações no wanderer desde então.', + 'wanderer.preview.changed': 'Importada em {date} como “{name}”. Alterada no wanderer desde então: {parts}.', + 'wanderer.preview.editedLocally': 'O passeio desta viagem foi editado aqui desde então.', + 'wanderer.preview.newVersionHint': + 'Importar de novo adiciona uma nova versão ao lado. O passeio da viagem continua como está.', + 'wanderer.preview.change.name': 'nome', + 'wanderer.preview.change.description': 'descrição', + 'wanderer.preview.change.route': 'rota', + 'wanderer.tourTitle': 'Passeio do wanderer', + 'wanderer.import.done': '“{name}” importada', + 'wanderer.import.stale': 'A trilha mudou no wanderer. Esta é a versão atual.', +}; +export default wanderer; diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 52c321bd6..d51a43672 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -260,6 +260,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Complement actualitzat', 'admin.addons.toast.error': "No s'ha pogut actualitzar el complement", 'admin.addons.group.count': '{enabled} de {total} actius', + 'admin.addons.subOptions': '{count} opcions', + 'admin.addons.subOptions.one': '{count} opció', + 'admin.addons.subOptionsOn': '{count} actives', + 'admin.addons.subOptionsOn.one': '{count} activa', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Els usuaris poden connectar la seva pròpia instància de wanderer i importar-ne els senders com a excursions', 'admin.addons.noAddons': 'No hi ha complements disponibles', 'admin.weather.title': 'Dades meteorològiques', 'admin.weather.badge': 'Des del 24 de març de 2026', diff --git a/shared/src/i18n/ca/index.ts b/shared/src/i18n/ca/index.ts index f1e33c665..7c9d6b114 100644 --- a/shared/src/i18n/ca/index.ts +++ b/shared/src/i18n/ca/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts index e96421864..abf1f3673 100644 --- a/shared/src/i18n/ca/tours.ts +++ b/shared/src/i18n/ca/tours.ts @@ -22,8 +22,64 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Importada amb reserves: altitud o mètriques incompletes', 'tours.import.success': '{count} excursions importades de GPX', 'tours.import.success.one': '{count} excursió importada de GPX', - 'tours.import.noTrack': "No s'ha trobat cap traça ni ruta en aquest fitxer GPX", - 'tours.import.error': "No s'ha pogut importar el fitxer GPX", + 'tours.import.noTrackIn': "No s'ha trobat cap traça ni ruta a {name}", + 'tours.import.errorIn': "No s'ha pogut importar {name}", + 'tours.import.button': 'Importa GPX', + 'tours.import.hint': "Importa fitxers GPX com a excursions d'aquest viatge", + 'tours.import.unsupported': 'Només es poden importar fitxers GPX com a excursions.', + 'tours.import.skipped': "{count} traces ja eren en aquest viatge i s'han omès", + 'tours.import.skipped.one': "{count} traça ja era en aquest viatge i s'ha omès", + 'tours.import.allSkipped': 'Tot el que hi ha en aquest fitxer ja és en aquest viatge.', + 'tours.import.drop': 'Deixa anar fitxers GPX per importar-los', + 'tours.import.dropHint': "Cada traça o ruta d'un fitxer es converteix en una excursió d'aquest viatge.", + 'tours.import.dropNoPermission': 'No pots afegir excursions a aquest viatge.', + 'tours.import.importing': "S'està important el GPX…", + 'tours.days.label': 'Dies del viatge', + 'tours.days.none': 'Encara no és a cap dia del viatge.', + 'tours.days.short': 'Dies {days}', + 'tours.days.short.one': 'Dia {days}', + 'tours.alreadyOnDay': 'Aquesta excursió ja és en aquest dia.', + 'tours.conversion.title': 'Versió editable', + 'tours.conversion.intro': + "Una importació és una traça enregistrada. Planifica'n una versió editable: TREK n'extreu punts de control, en calcula la ruta de nou i mostra com s'hi acosta. No canvia res fins que triïs.", + 'tours.conversion.replace': 'Substitueix la importació', + 'tours.conversion.replaceHint': + 'Torna a planificar aquesta excursió al mateix lloc: els dies i les notes es mantenen, i la traça enregistrada es conserva perquè hi puguis tornar.', + 'tours.conversion.adoptHint': 'Planifica una excursió nova al costat de la importació, que es queda tal com és.', + 'tours.conversion.replacing': + "Oberta a l'editor. Desa-la per substituir la importació; la traça enregistrada es pot continuar restaurant.", + 'tours.recorded.title': 'Traça enregistrada', + 'tours.recorded.hint': + "Aquesta excursió s'ha tornat a planificar a partir d'una importació. Es conserva la traça amb què es va enregistrar.", + 'tours.recorded.restore': 'Restaura la traça enregistrada', + 'tours.recorded.confirmTitle': 'Vols restaurar la traça enregistrada?', + 'tours.recorded.confirmBody': + "Els punts de pas i la línia calculada es descarten, i l'excursió torna a mostrar la traça amb què es va enregistrar. Els dies i les notes es mantenen.", + 'tours.recorded.restored': 'Traça enregistrada restaurada', + 'tours.recorded.failed': "No s'ha pogut restaurar la traça enregistrada.", + 'tours.conversion.analyze': 'Planifica una versió editable', + 'tours.conversion.analyzing': "S'està calculant la ruta per {count} punts de control…", + 'tours.conversion.analyzing.one': "S'està calculant la ruta per {count} punt de control…", + 'tours.conversion.failedTrack': 'Aquesta traça és massa curta per planificar-ne una excursió.', + 'tours.conversion.failedRouting': "No s'ha trobat cap ruta pels punts de control.", + 'tours.conversion.controlPoints': 'Punts de control', + 'tours.conversion.withinCorridor': 'A menys de {m} m de la traça', + 'tours.conversion.importLength': 'Importació', + 'tours.conversion.suggestionLength': 'Proposta', + 'tours.conversion.maxDeviation': 'Desviació màxima', + 'tours.conversion.legendImport': 'Importació', + 'tours.conversion.legendSuggestion': 'Proposta', + 'tours.conversion.close': + "La proposta segueix de prop la traça. La ruta s'ha calculat de nou, així que no és la mateixa línia.", + 'tours.conversion.different': + "La proposta s'aparta de la traça en trams llargs. Revisa-la al mapa abans de planificar-hi.", + 'tours.conversion.adopt': 'Planifica com a excursió nova', + 'tours.conversion.discard': 'Descarta', + 'tours.conversion.adopted': + 'Oberta com a excursió nova. Desa-la per conservar-la; la importació es queda tal com és.', + 'tours.derivedFrom': 'Planificada a partir de {name}', + 'tours.derivedFromHint': + "Ruta calculada de nou a partir d'una importació, així que pot diferir de la traça enregistrada.", 'tours.delete.confirmBody': 'Vols eliminar aquest itinerari permanentment? Se n’eliminaran la ruta, els punts editables i les assignacions als dies. Aquesta acció no es pot desfer.', 'tours.delete.bulkConfirmBody': @@ -38,9 +94,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Altitud màxima', 'tours.detail.ascent': 'Desnivell positiu', 'tours.detail.descent': 'Desnivell negatiu', - 'tours.detail.difficulty': 'Dificultat', 'tours.detail.source': 'Origen', - 'tours.detail.gpxReadOnly': 'Les excursions GPX són de només lectura i encara no es poden editar al planificador.', 'tours.detail.navigationHint': "La navegació obre el punt d'inici del sender; no guia al llarg de la ruta.", 'tours.planner.title': 'Planifica una excursió', 'tours.planner.newTour': 'Planifica una nova excursió', diff --git a/shared/src/i18n/ca/wanderer.ts b/shared/src/i18n/ca/wanderer.ts new file mode 100644 index 000000000..482ca9273 --- /dev/null +++ b/shared/src/i18n/ca/wanderer.ts @@ -0,0 +1,94 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Connectat', + 'wanderer.notConnected': 'No connectat', + 'wanderer.intro': + 'Connecta la teva pròpia instància de wanderer per importar-ne els senders com a excursions. El token es queda al servidor i no es torna a mostrar mai.', + 'wanderer.url': 'Adreça de la instància', + 'wanderer.token': "Token d'API", + 'wanderer.tokenHint': "A wanderer, a Configuració, Compte, Tokens d'API.", + 'wanderer.tokenInvalid': "Això no és un token d'API de wanderer. Un token comença per wanderer_key_.", + 'wanderer.allowInsecureTls': 'Permet un certificat autosignat', + 'wanderer.allowInsecureTlsHint': 'Només per a una instància a la teva pròpia xarxa.', + 'wanderer.test.button': 'Prova la connexió', + 'wanderer.test.ok': 'Connectat.', + 'wanderer.test.okCount': 'Connectat. Pots importar {count} dels teus senders.', + 'wanderer.test.okCount.one': 'Connectat. Pots importar {count} dels teus senders.', + 'wanderer.disconnect': 'Desconnecta', + 'wanderer.saved': 'Connexió amb wanderer desada', + 'wanderer.disconnected': 'Connexió amb wanderer eliminada', + 'wanderer.loadFailed': "No s'ha pogut carregar la connexió amb wanderer", + 'wanderer.privateIp': + "Aquesta adreça és en una xarxa privada. Si TREK no hi pot arribar, l'administrador ha de permetre les xarxes internes.", + 'wanderer.error.disabled': 'El teu administrador no ha activat wanderer per a les excursions.', + 'wanderer.error.not_connected': 'wanderer encara no està connectat.', + 'wanderer.error.invalid_url': 'Aquesta adreça no és un URL de wanderer vàlid.', + 'wanderer.error.blocked_address': + "TREK no pot accedir a aquesta adreça. Per a una instància a la teva pròpia xarxa, l'administrador ha de definir ALLOW_INTERNAL_NETWORK=true.", + 'wanderer.error.unreachable': + "No s'ha pogut contactar amb wanderer. Comprova l'adreça i que la instància estigui en marxa.", + 'wanderer.error.tls': "No s'ha pogut verificar el certificat de la instància. Si és autosignat, permet-lo a sota.", + 'wanderer.error.unauthorized': "wanderer no ha acceptat el token d'API. Potser s'ha revocat o ha caducat.", + 'wanderer.error.forbidden': "wanderer ha denegat l'accés a aquest sender.", + 'wanderer.error.not_found': 'Aquest sender ja no existeix a wanderer.', + 'wanderer.error.not_own_trail': 'Només es poden importar els teus propis senders de wanderer.', + 'wanderer.error.invalid_response': "La resposta no ve de wanderer. Comprova que l'adreça apunti al mateix wanderer.", + 'wanderer.error.too_large': 'El fitxer del sender ocupa més de 10 MB.', + 'wanderer.error.server_error': 'wanderer ha respost amb un error. Torna-ho a provar més tard.', + 'wanderer.error.offline': 'No tens connexió. Només es pot cercar a wanderer amb connexió.', + 'wanderer.error.preview_stale': 'El sender ha canviat a wanderer mentre el miraves.', + 'wanderer.error.already_imported': 'Aquest sender ja és al viatge.', + 'wanderer.error.no_track': 'Aquest sender no té cap traça ni ruta per importar.', + 'wanderer.error.unknown': 'Alguna cosa ha fallat amb wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Els teus senders', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Importa un sender de la teva instància de wanderer', + 'wanderer.browser.backToList': 'Torna als resultats', + 'wanderer.browser.searchPlaceholder': 'Cerca senders', + 'wanderer.browser.areaOff': 'Cerca en aquesta zona del mapa', + 'wanderer.browser.areaOn': "S'està cercant en aquesta zona del mapa", + 'wanderer.browser.areaOnHint': 'Només senders que entren a la part del mapa que veus', + 'wanderer.browser.areaOffHint': 'Torna a cercar a tot arreu', + 'wanderer.browser.connectTitle': 'Connecta wanderer', + 'wanderer.browser.connect': 'Connecta a la configuració', + 'wanderer.browser.empty': "No s'ha trobat cap sender", + 'wanderer.browser.emptyArea': 'No hi ha senders en aquesta zona del mapa', + 'wanderer.browser.emptyHint': 'Prova una altra paraula o mou el mapa.', + 'wanderer.browser.count': '{count} senders', + 'wanderer.browser.count.one': '{count} sender', + 'wanderer.browser.more': "Carrega'n més", + 'wanderer.browser.public': 'Públic', + 'wanderer.browser.private': 'Privat', + 'wanderer.browser.publicHint': 'Públic a wanderer: tothom a la teva instància el pot veure allà', + 'wanderer.browser.privateHint': 'Privat a wanderer: només tu el pots veure allà', + 'wanderer.browser.inTrip': 'En aquest viatge', + 'wanderer.browser.inTripHint': 'Ja importat en aquest viatge', + 'wanderer.browser.areaBadge': 'Zona del mapa', + 'wanderer.preview.loading': "S'està llegint el sender de wanderer…", + 'wanderer.preview.measured': 'Segons el fitxer GPX', + 'wanderer.preview.points': 'Punts de la traça', + 'wanderer.preview.noElevation': 'El fitxer no té altitud, així que la pujada i la baixada són desconegudes.', + 'wanderer.preview.openExternal': 'Obre a wanderer', + 'wanderer.preview.import': 'Importa al viatge', + 'wanderer.preview.importNewVersion': 'Importa com a versió nova', + 'wanderer.preview.openSnapshot': "Obre l'excursió", + 'wanderer.preview.openSnapshotShort': "Obre l'excursió en aquest viatge", + 'wanderer.preview.newHint': + 'En importar, el sender es copia en aquest viatge com una excursió pròpia. Els canvis posteriors a wanderer no la modifiquen.', + 'wanderer.preview.unchanged': + 'Ja és en aquest viatge com a «{name}», importat el {date} i sense canvis a wanderer des de llavors.', + 'wanderer.preview.changed': 'Importat el {date} com a «{name}». Canviat a wanderer des de llavors: {parts}.', + 'wanderer.preview.editedLocally': "L'excursió d'aquest viatge s'ha editat aquí des de llavors.", + 'wanderer.preview.newVersionHint': + "Si tornes a importar, s'afegeix una versió nova al costat. L'excursió del viatge es queda tal com és.", + 'wanderer.preview.change.name': 'nom', + 'wanderer.preview.change.description': 'descripció', + 'wanderer.preview.change.route': 'ruta', + 'wanderer.tourTitle': 'Excursió de wanderer', + 'wanderer.import.done': '«{name}» importat', + 'wanderer.import.stale': 'El sender ha canviat a wanderer. Aquesta és la versió actual.', +}; +export default wanderer; diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 0813deaeb..65c0e1f13 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -652,6 +652,15 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Doplněk byl aktualizován', 'admin.addons.toast.error': 'Aktualizace doplňku se nezdařila', 'admin.addons.group.count': '{enabled} z {total} zapnuto', + 'admin.addons.subOptions': '{count} možností', + 'admin.addons.subOptions.one': '{count} možnost', + 'admin.addons.subOptions.few': '{count} možnosti', + 'admin.addons.subOptionsOn': 'zapnuto: {count}', + 'admin.addons.subOptionsOn.one': 'zapnuto: {count}', + 'admin.addons.subOptionsOn.few': 'zapnuto: {count}', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Uživatelé mohou připojit vlastní instanci wanderer a importovat z ní trasy jako výlety', 'admin.addons.noAddons': 'Žádné doplňky nejsou k dispozici', 'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.description': 'Model Context Protocol pro integraci AI asistentů', diff --git a/shared/src/i18n/cs/index.ts b/shared/src/i18n/cs/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/cs/index.ts +++ b/shared/src/i18n/cs/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts index 11b0d8a67..4520b360b 100644 --- a/shared/src/i18n/cs/tours.ts +++ b/shared/src/i18n/cs/tours.ts @@ -23,8 +23,63 @@ const tours: TranslationStrings = { 'tours.import.success': 'Z GPX bylo importováno {count} výletů', 'tours.import.success.one': 'Z GPX byl importován {count} výlet', 'tours.import.success.few': 'Z GPX byly importovány {count} výlety', - 'tours.import.noTrack': 'V tomto souboru GPX nebyla nalezena stopa ani trasa', - 'tours.import.error': 'Soubor GPX se nepodařilo importovat', + 'tours.import.noTrackIn': 'V souboru {name} nebyla nalezena stopa ani trasa', + 'tours.import.errorIn': 'Soubor {name} se nepodařilo importovat', + 'tours.import.button': 'Importovat GPX', + 'tours.import.hint': 'Importujte soubory GPX jako výlety této cesty', + 'tours.import.unsupported': 'Jako výlety lze importovat pouze soubory GPX.', + 'tours.import.skipped': '{count} stop už v této cestě bylo, proto byly přeskočeny', + 'tours.import.skipped.one': '{count} stopa už v této cestě byla, proto byla přeskočena', + 'tours.import.skipped.few': '{count} stopy už v této cestě byly, proto byly přeskočeny', + 'tours.import.allSkipped': 'Vše z tohoto souboru už v této cestě je.', + 'tours.import.drop': 'Pusťte sem soubory GPX a importujte je', + 'tours.import.dropHint': 'Každá stopa nebo trasa v souboru se stane výletem této cesty.', + 'tours.import.dropNoPermission': 'Do této cesty nemůžete přidávat výlety.', + 'tours.import.importing': 'Importování GPX…', + 'tours.days.label': 'Dny cesty', + 'tours.days.none': 'Zatím není přiřazen k žádnému dni cesty.', + 'tours.days.short': 'Dny {days}', + 'tours.days.short.one': 'Den {days}', + 'tours.days.short.few': 'Dny {days}', + 'tours.alreadyOnDay': 'Tento výlet už je k tomuto dni přiřazen.', + 'tours.conversion.title': 'Upravitelná verze', + 'tours.conversion.intro': + 'Import je zaznamenaná stopa. Naplánujte upravitelnou verzi: TREK z ní vezme kontrolní body, znovu přes ně vypočte trasu a ukáže, jak blízko se výsledek drží. Dokud se nerozhodnete, nic se nezmění.', + 'tours.conversion.replace': 'Nahradit import', + 'tours.conversion.replaceHint': + 'Naplánuje tento výlet znovu na místě: jeho dny a poznámky zůstanou a zaznamenaná stopa se uchová, abyste se k ní mohli vrátit.', + 'tours.conversion.adoptHint': 'Naplánuje nový výlet vedle importu, který zůstane beze změny.', + 'tours.conversion.replacing': + 'Otevřeno v editoru. Uložte ho, abyste nahradili import; zaznamenanou stopu bude stále možné obnovit.', + 'tours.recorded.title': 'Zaznamenaná stopa', + 'tours.recorded.hint': 'Tento výlet byl znovu naplánován nad importem. Stopa, se kterou byl zaznamenán, je uchována.', + 'tours.recorded.restore': 'Obnovit zaznamenanou stopu', + 'tours.recorded.confirmTitle': 'Obnovit zaznamenanou stopu?', + 'tours.recorded.confirmBody': + 'Průjezdní body a vypočtená linie budou zahozeny a výlet bude opět zobrazovat stopu, se kterou byl zaznamenán. Jeho dny a poznámky zůstanou.', + 'tours.recorded.restored': 'Zaznamenaná stopa obnovena', + 'tours.recorded.failed': 'Zaznamenanou stopu se nepodařilo obnovit.', + 'tours.conversion.analyze': 'Naplánovat upravitelnou verzi', + 'tours.conversion.analyzing': 'Výpočet trasy přes {count} kontrolních bodů…', + 'tours.conversion.analyzing.one': 'Výpočet trasy přes {count} kontrolní bod…', + 'tours.conversion.analyzing.few': 'Výpočet trasy přes {count} kontrolní body…', + 'tours.conversion.failedTrack': 'Tato stopa je příliš krátká na naplánování výletu.', + 'tours.conversion.failedRouting': 'Přes kontrolní body nebyla nalezena žádná trasa.', + 'tours.conversion.controlPoints': 'Kontrolní body', + 'tours.conversion.withinCorridor': 'Do {m} m od stopy', + 'tours.conversion.importLength': 'Import', // same-as-en + 'tours.conversion.suggestionLength': 'Návrh', + 'tours.conversion.maxDeviation': 'Největší odchylka', + 'tours.conversion.legendImport': 'Import', // same-as-en + 'tours.conversion.legendSuggestion': 'Návrh', + 'tours.conversion.close': 'Návrh se drží blízko stopy. Trasa je vypočtena znovu, takže nejde o stejnou linii.', + 'tours.conversion.different': + 'Návrh se na delších úsecích od stopy odchyluje. Než s ním začnete plánovat, zkontrolujte ho na mapě.', + 'tours.conversion.adopt': 'Naplánovat jako nový výlet', + 'tours.conversion.discard': 'Zahodit', + 'tours.conversion.adopted': 'Otevřeno jako nový výlet. Uložte ho, aby zůstal zachován; import zůstane beze změny.', + 'tours.derivedFrom': 'Naplánováno z {name}', + 'tours.derivedFromHint': 'Trasa je z importu vypočtena znovu, proto se může od zaznamenané stopy lišit.', 'tours.delete.confirmBody': 'Opravdu chcete tento výlet trvale odstranit? Jeho trasu, upravitelné body a přiřazení ke dnům odstraníte. Tuto akci nelze vrátit zpět.', 'tours.delete.bulkConfirmBody': @@ -38,9 +93,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Maximální nadmořská výška', 'tours.detail.ascent': 'Stoupání', 'tours.detail.descent': 'Klesání', - 'tours.detail.difficulty': 'Obtížnost', 'tours.detail.source': 'Zdroj', - 'tours.detail.gpxReadOnly': 'Výlety GPX lze pouze prohlížet; v plánovači je zatím nelze upravovat.', 'tours.detail.navigationHint': 'Navigace otevře výchozí bod túry; nevede po její trase.', 'tours.planner.title': 'Naplánovat výlet', 'tours.planner.newTour': 'Naplánovat nový výlet', diff --git a/shared/src/i18n/cs/wanderer.ts b/shared/src/i18n/cs/wanderer.ts new file mode 100644 index 000000000..c2cadc8d8 --- /dev/null +++ b/shared/src/i18n/cs/wanderer.ts @@ -0,0 +1,95 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Připojeno', + 'wanderer.notConnected': 'Nepřipojeno', + 'wanderer.intro': + 'Připojte vlastní instanci wanderer a importujte z ní trasy jako výlety. Token zůstane na serveru a už se nikdy nezobrazí.', + 'wanderer.url': 'Adresa instance', + 'wanderer.token': 'Token API', + 'wanderer.tokenHint': 'V aplikaci wanderer v části Nastavení, Účet, Tokeny API.', + 'wanderer.tokenInvalid': 'Toto není token API aplikace wanderer. Token začíná na wanderer_key_.', + 'wanderer.allowInsecureTls': 'Povolit certifikát podepsaný sám sebou', + 'wanderer.allowInsecureTlsHint': 'Pouze pro instanci ve vaší vlastní síti.', + 'wanderer.test.button': 'Otestovat připojení', + 'wanderer.test.ok': 'Připojeno.', + 'wanderer.test.okCount': 'Připojeno. Můžete importovat {count} svých tras.', + 'wanderer.test.okCount.one': 'Připojeno. Můžete importovat {count} svou trasu.', + 'wanderer.test.okCount.few': 'Připojeno. Můžete importovat {count} své trasy.', + 'wanderer.disconnect': 'Odpojit', + 'wanderer.saved': 'Připojení k wanderer uloženo', + 'wanderer.disconnected': 'Připojení k wanderer odebráno', + 'wanderer.loadFailed': 'Připojení k wanderer se nepodařilo načíst', + 'wanderer.privateIp': + 'Tato adresa je v privátní síti. Pokud se k ní TREK nedokáže připojit, musí administrátor povolit interní sítě.', + 'wanderer.error.disabled': 'Váš administrátor nepovolil wanderer pro výlety.', + 'wanderer.error.not_connected': 'wanderer zatím není připojen.', + 'wanderer.error.invalid_url': 'Tato adresa není platná URL instance wanderer.', + 'wanderer.error.blocked_address': + 'TREK se k této adrese nesmí připojit. Pro instanci ve vaší vlastní síti musí administrátor nastavit ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': 'wanderer není dostupný. Zkontrolujte adresu a zda instance běží.', + 'wanderer.error.tls': 'Certifikát instance se nepodařilo ověřit. Pokud je podepsaný sám sebou, povolte ho níže.', + 'wanderer.error.unauthorized': 'wanderer nepřijal token API. Mohl být zrušen nebo mu vypršela platnost.', + 'wanderer.error.forbidden': 'wanderer odepřel přístup k této trase.', + 'wanderer.error.not_found': 'Tato trasa už v aplikaci wanderer neexistuje.', + 'wanderer.error.not_own_trail': 'Importovat lze pouze vaše vlastní trasy z aplikace wanderer.', + 'wanderer.error.invalid_response': + 'Odpověď nepřišla z aplikace wanderer. Zkontrolujte, zda adresa vede přímo na wanderer.', + 'wanderer.error.too_large': 'Soubor trasy je větší než 10 MB.', + 'wanderer.error.server_error': 'wanderer odpověděl chybou. Zkuste to později.', + 'wanderer.error.offline': 'Jste offline. V aplikaci wanderer lze vyhledávat jen online.', + 'wanderer.error.preview_stale': 'Trasa se v aplikaci wanderer změnila, zatímco jste si ji prohlíželi.', + 'wanderer.error.already_imported': 'Tato trasa už v cestě je.', + 'wanderer.error.no_track': 'Tato trasa neobsahuje žádnou stopu k importu.', + 'wanderer.error.unknown': 'Při práci s aplikací wanderer se něco pokazilo.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Vaše trasy', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Importovat trasu z vaší instance wanderer', + 'wanderer.browser.backToList': 'Zpět na výsledky', + 'wanderer.browser.searchPlaceholder': 'Hledat trasy', + 'wanderer.browser.areaOff': 'Hledat v této oblasti mapy', + 'wanderer.browser.areaOn': 'Hledá se v této oblasti mapy', + 'wanderer.browser.areaOnHint': 'Jen trasy, které zasahují do viditelné části mapy', + 'wanderer.browser.areaOffHint': 'Znovu hledat všude', + 'wanderer.browser.connectTitle': 'Připojit wanderer', + 'wanderer.browser.connect': 'Připojit v nastavení', + 'wanderer.browser.empty': 'Nebyly nalezeny žádné trasy', + 'wanderer.browser.emptyArea': 'V této oblasti mapy nejsou žádné trasy', + 'wanderer.browser.emptyHint': 'Zkuste jiné slovo nebo posuňte mapu.', + 'wanderer.browser.count': '{count} tras', + 'wanderer.browser.count.one': '{count} trasa', + 'wanderer.browser.count.few': '{count} trasy', + 'wanderer.browser.more': 'Načíst další', + 'wanderer.browser.public': 'Veřejná', + 'wanderer.browser.private': 'Soukromá', + 'wanderer.browser.publicHint': 'Veřejná v aplikaci wanderer: vidí ji tam každý na vaší instanci', + 'wanderer.browser.privateHint': 'Soukromá v aplikaci wanderer: vidíte ji tam jen vy', + 'wanderer.browser.inTrip': 'V této cestě', + 'wanderer.browser.inTripHint': 'Již importováno do této cesty', + 'wanderer.browser.areaBadge': 'Oblast mapy', + 'wanderer.preview.loading': 'Načítání trasy z aplikace wanderer…', + 'wanderer.preview.measured': 'Ze souboru GPX', + 'wanderer.preview.points': 'Body stopy', + 'wanderer.preview.noElevation': 'Soubor neobsahuje výškové údaje, stoupání a klesání proto nejsou známé.', + 'wanderer.preview.openExternal': 'Otevřít v aplikaci wanderer', + 'wanderer.preview.import': 'Importovat do cesty', + 'wanderer.preview.importNewVersion': 'Importovat jako novou verzi', + 'wanderer.preview.openSnapshot': 'Otevřít výlet', + 'wanderer.preview.openSnapshotShort': 'Otevřít výlet v této cestě', + 'wanderer.preview.newHint': + 'Import zkopíruje trasu do této cesty jako samostatný výlet. Pozdější změny v aplikaci wanderer ho neovlivní.', + 'wanderer.preview.unchanged': + 'Už je v této cestě jako „{name}“, importováno {date} a od té doby se v aplikaci wanderer nezměnilo.', + 'wanderer.preview.changed': 'Importováno {date} jako „{name}“. Od té doby se v aplikaci wanderer změnilo: {parts}.', + 'wanderer.preview.editedLocally': 'Výlet v této cestě byl od té doby upraven zde.', + 'wanderer.preview.newVersionHint': 'Opětovný import přidá vedle něj novou verzi. Výlet v cestě zůstane beze změny.', + 'wanderer.preview.change.name': 'název', + 'wanderer.preview.change.description': 'popis', + 'wanderer.preview.change.route': 'trasa', + 'wanderer.tourTitle': 'Výlet z aplikace wanderer', + 'wanderer.import.done': 'Importováno „{name}“', + 'wanderer.import.stale': 'Trasa se v aplikaci wanderer změnila. Zde je aktuální verze.', +}; +export default wanderer; diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 2a2795360..850918fe5 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -659,6 +659,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Addon aktualisiert', 'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden', 'admin.addons.group.count': '{enabled} von {total} aktiviert', + 'admin.addons.subOptions': '{count} Optionen', + 'admin.addons.subOptions.one': '{count} Option', + 'admin.addons.subOptionsOn': '{count} aktiv', + 'admin.addons.subOptionsOn.one': '{count} aktiv', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Nutzer können ihre eigene wanderer-Instanz verbinden und deren Trails als Touren importieren', 'admin.addons.noAddons': 'Keine Addons verfügbar', 'admin.weather.title': 'Wetterdaten', 'admin.weather.badge': 'Seit 24. März 2026', diff --git a/shared/src/i18n/de/index.ts b/shared/src/i18n/de/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/de/index.ts +++ b/shared/src/i18n/de/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts index 6eacdb7cb..734b5f42a 100644 --- a/shared/src/i18n/de/tours.ts +++ b/shared/src/i18n/de/tours.ts @@ -22,8 +22,62 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Mit Vorsicht importiert – Höhendaten oder Streckenwerte unvollständig', 'tours.import.success': '{count} Touren aus GPX importiert', 'tours.import.success.one': '{count} Tour aus GPX importiert', - 'tours.import.noTrack': 'Keine Strecke oder Route in dieser GPX-Datei gefunden', - 'tours.import.error': 'GPX-Datei konnte nicht importiert werden', + 'tours.import.noTrackIn': 'Keine Strecke oder Route in {name} gefunden', + 'tours.import.errorIn': '{name} konnte nicht importiert werden', + 'tours.import.button': 'GPX importieren', + 'tours.import.hint': 'GPX-Dateien als Touren dieser Reise importieren', + 'tours.import.unsupported': 'Nur GPX-Dateien können als Touren importiert werden.', + 'tours.import.skipped': '{count} Strecken waren schon in dieser Reise und wurden übersprungen', + 'tours.import.skipped.one': '{count} Strecke war schon in dieser Reise und wurde übersprungen', + 'tours.import.allSkipped': 'Alles aus dieser Datei ist schon in dieser Reise.', + 'tours.import.drop': 'GPX-Dateien hier ablegen, um sie zu importieren', + 'tours.import.dropHint': 'Jede Strecke oder Route in einer Datei wird zu einer Tour dieser Reise.', + 'tours.import.dropNoPermission': 'Du kannst dieser Reise keine Touren hinzufügen.', + 'tours.import.importing': 'GPX wird importiert…', + 'tours.days.label': 'Reisetage', + 'tours.days.none': 'Noch an keinem Tag der Reise.', + 'tours.days.short': 'Tage {days}', + 'tours.days.short.one': 'Tag {days}', + 'tours.alreadyOnDay': 'Diese Tour ist schon an diesem Tag.', + 'tours.conversion.title': 'Bearbeitbare Version', + 'tours.conversion.intro': + 'Ein Import ist eine aufgezeichnete Strecke. Plane eine bearbeitbare Version: TREK nimmt Kontrollpunkte daraus, routet sie neu und zeigt, wie nah das herankommt. Nichts ändert sich, bis du dich entscheidest.', + 'tours.conversion.replace': 'Import ersetzen', + 'tours.conversion.replaceHint': + 'Plant diese Tour an Ort und Stelle neu: Ihre Tage und Notizen bleiben, und die aufgezeichnete Strecke wird aufbewahrt, damit du zu ihr zurückkehren kannst.', + 'tours.conversion.adoptHint': 'Plant eine neue Tour neben dem Import, der bleibt, wie er ist.', + 'tours.conversion.replacing': + 'Im Editor geöffnet. Speichere sie, um den Import zu ersetzen; die aufgezeichnete Strecke bleibt wiederherstellbar.', + 'tours.recorded.title': 'Aufgezeichnete Strecke', + 'tours.recorded.hint': + 'Diese Tour wurde über einen Import neu geplant. Die Strecke, mit der sie aufgezeichnet wurde, ist aufbewahrt.', + 'tours.recorded.restore': 'Aufgezeichnete Strecke wiederherstellen', + 'tours.recorded.confirmTitle': 'Aufgezeichnete Strecke wiederherstellen?', + 'tours.recorded.confirmBody': + 'Die Wegpunkte und die geroutete Linie werden verworfen, und die Tour zeigt wieder die Strecke, mit der sie aufgezeichnet wurde. Ihre Tage und Notizen bleiben.', + 'tours.recorded.restored': 'Aufgezeichnete Strecke wiederhergestellt', + 'tours.recorded.failed': 'Die aufgezeichnete Strecke konnte nicht wiederhergestellt werden.', + 'tours.conversion.analyze': 'Bearbeitbare Version planen', + 'tours.conversion.analyzing': 'Route durch {count} Kontrollpunkte wird berechnet…', + 'tours.conversion.analyzing.one': 'Route durch {count} Kontrollpunkt wird berechnet…', + 'tours.conversion.failedTrack': 'Diese Strecke ist zu kurz, um daraus eine Tour zu planen.', + 'tours.conversion.failedRouting': 'Durch die Kontrollpunkte wurde keine Route gefunden.', + 'tours.conversion.controlPoints': 'Kontrollpunkte', + 'tours.conversion.withinCorridor': 'Höchstens {m} m neben der Strecke', + 'tours.conversion.importLength': 'Import', // same-as-en + 'tours.conversion.suggestionLength': 'Vorschlag', + 'tours.conversion.maxDeviation': 'Größte Abweichung', + 'tours.conversion.legendImport': 'Import', // same-as-en + 'tours.conversion.legendSuggestion': 'Vorschlag', + 'tours.conversion.close': 'Der Vorschlag folgt der Strecke eng. Er ist neu geroutet und daher nicht dieselbe Linie.', + 'tours.conversion.different': + 'Der Vorschlag verlässt die Strecke über längere Abschnitte. Prüfe ihn auf der Karte, bevor du damit planst.', + 'tours.conversion.adopt': 'Als neue Tour planen', + 'tours.conversion.discard': 'Verwerfen', + 'tours.conversion.adopted': + 'Als neue Tour geöffnet. Speichere sie, um sie zu behalten; der Import bleibt, wie er ist.', + 'tours.derivedFrom': 'Geplant aus {name}', + 'tours.derivedFromHint': 'Aus einem Import neu geroutet, kann daher von der aufgezeichneten Strecke abweichen.', 'tours.delete.confirmBody': 'Diese Tour dauerhaft löschen? Ihre Route, bearbeitbaren Wegpunkte und Tageszuweisungen werden entfernt. Dieser Vorgang kann nicht rückgängig gemacht werden.', 'tours.delete.bulkConfirmBody': @@ -38,9 +92,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Höchster Punkt', 'tours.detail.ascent': 'Aufstieg', 'tours.detail.descent': 'Abstieg', - 'tours.detail.difficulty': 'Schwierigkeit', 'tours.detail.source': 'Quelle', - 'tours.detail.gpxReadOnly': 'GPX-Touren sind schreibgeschützt und können im Planer noch nicht bearbeitet werden.', 'tours.detail.navigationHint': 'Die Navigation führt zum Startpunkt, nicht entlang der Tour.', 'tours.planner.title': 'Tour planen', 'tours.planner.newTour': 'Neue Tour planen', diff --git a/shared/src/i18n/de/wanderer.ts b/shared/src/i18n/de/wanderer.ts new file mode 100644 index 000000000..cd5539268 --- /dev/null +++ b/shared/src/i18n/de/wanderer.ts @@ -0,0 +1,96 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Verbunden', + 'wanderer.notConnected': 'Nicht verbunden', + 'wanderer.intro': + 'Verbinde deine eigene wanderer-Instanz, um ihre Trails als Touren zu importieren. Der Token bleibt auf dem Server und wird nie wieder angezeigt.', + 'wanderer.url': 'Adresse der Instanz', + 'wanderer.token': 'API-Token', + 'wanderer.tokenHint': 'In wanderer unter Einstellungen, Konto, API-Tokens.', + 'wanderer.tokenInvalid': 'Das ist kein wanderer-API-Token. Ein Token beginnt mit wanderer_key_.', + 'wanderer.allowInsecureTls': 'Selbstsigniertes Zertifikat zulassen', + 'wanderer.allowInsecureTlsHint': 'Nur für eine Instanz in deinem eigenen Netzwerk.', + 'wanderer.test.button': 'Verbindung testen', + 'wanderer.test.ok': 'Verbunden.', + 'wanderer.test.okCount': 'Verbunden. Du kannst {count} deiner Trails importieren.', + 'wanderer.test.okCount.one': 'Verbunden. Du kannst {count} deiner Trails importieren.', + 'wanderer.disconnect': 'Trennen', + 'wanderer.saved': 'wanderer-Verbindung gespeichert', + 'wanderer.disconnected': 'wanderer-Verbindung entfernt', + 'wanderer.loadFailed': 'Die wanderer-Verbindung konnte nicht geladen werden', + 'wanderer.privateIp': + 'Diese Adresse liegt in einem privaten Netzwerk. Wenn TREK sie nicht erreicht, muss der Administrator interne Netzwerke erlauben.', + 'wanderer.error.disabled': 'Dein Admin hat wanderer für Touren nicht aktiviert.', + 'wanderer.error.not_connected': 'wanderer ist noch nicht verbunden.', + 'wanderer.error.invalid_url': 'Diese Adresse ist keine gültige wanderer-URL.', + 'wanderer.error.blocked_address': + 'TREK darf diese Adresse nicht erreichen. Für eine Instanz in deinem eigenen Netzwerk muss der Administrator ALLOW_INTERNAL_NETWORK=true setzen.', + 'wanderer.error.unreachable': 'wanderer war nicht erreichbar. Prüfe die Adresse und ob die Instanz läuft.', + 'wanderer.error.tls': + 'Das Zertifikat der Instanz konnte nicht geprüft werden. Ein selbstsigniertes kannst du unten zulassen.', + 'wanderer.error.unauthorized': + 'wanderer hat den API-Token nicht akzeptiert. Vielleicht wurde er widerrufen oder ist abgelaufen.', + 'wanderer.error.forbidden': 'wanderer hat den Zugriff auf diesen Trail verweigert.', + 'wanderer.error.not_found': 'Diesen Trail gibt es in wanderer nicht mehr.', + 'wanderer.error.not_own_trail': 'Nur deine eigenen wanderer-Trails können importiert werden.', + 'wanderer.error.invalid_response': + 'Die Antwort kam nicht von wanderer. Prüfe, ob die Adresse auf wanderer selbst zeigt.', + 'wanderer.error.too_large': 'Die Trail-Datei ist größer als 10 MB.', + 'wanderer.error.server_error': 'wanderer hat mit einem Fehler geantwortet. Versuche es später noch einmal.', + 'wanderer.error.offline': 'Du bist offline. wanderer lässt sich nur online durchsuchen.', + 'wanderer.error.preview_stale': 'Der Trail hat sich in wanderer geändert, während du ihn angesehen hast.', + 'wanderer.error.already_imported': 'Dieser Trail ist schon in der Reise.', + 'wanderer.error.no_track': 'Dieser Trail hat keine Strecke oder Route zum Importieren.', + 'wanderer.error.unknown': 'Bei wanderer ist etwas schiefgelaufen.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Deine Trails', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Einen Trail aus deiner wanderer-Instanz importieren', + 'wanderer.browser.backToList': 'Zurück zu den Ergebnissen', + 'wanderer.browser.searchPlaceholder': 'Trails durchsuchen', + 'wanderer.browser.areaOff': 'In diesem Kartenausschnitt suchen', + 'wanderer.browser.areaOn': 'Suche in diesem Kartenausschnitt', + 'wanderer.browser.areaOnHint': 'Nur Trails, die in den sichtbaren Teil der Karte reichen', + 'wanderer.browser.areaOffHint': 'Wieder überall suchen', + 'wanderer.browser.connectTitle': 'wanderer verbinden', + 'wanderer.browser.connect': 'In den Einstellungen verbinden', + 'wanderer.browser.empty': 'Keine Trails gefunden', + 'wanderer.browser.emptyArea': 'Keine Trails in diesem Kartenausschnitt', + 'wanderer.browser.emptyHint': 'Versuche ein anderes Wort oder verschiebe die Karte.', + 'wanderer.browser.count': '{count} Trails', + 'wanderer.browser.count.one': '{count} Trail', + 'wanderer.browser.more': 'Mehr laden', + 'wanderer.browser.public': 'Öffentlich', + 'wanderer.browser.private': 'Privat', + 'wanderer.browser.publicHint': 'Öffentlich in wanderer: alle auf deiner Instanz können ihn dort sehen', + 'wanderer.browser.privateHint': 'Privat in wanderer: nur du kannst ihn dort sehen', + 'wanderer.browser.inTrip': 'In dieser Reise', + 'wanderer.browser.inTripHint': 'Schon in diese Reise importiert', + 'wanderer.browser.areaBadge': 'Kartenausschnitt', + 'wanderer.preview.loading': 'Trail wird aus wanderer gelesen…', + 'wanderer.preview.measured': 'Aus der GPX-Datei', + 'wanderer.preview.points': 'Streckenpunkte', + 'wanderer.preview.noElevation': 'Die Datei enthält keine Höhendaten, Auf- und Abstieg sind daher unbekannt.', + 'wanderer.preview.openExternal': 'In wanderer öffnen', + 'wanderer.preview.import': 'In die Reise importieren', + 'wanderer.preview.importNewVersion': 'Als neue Version importieren', + 'wanderer.preview.openSnapshot': 'Tour öffnen', + 'wanderer.preview.openSnapshotShort': 'Tour in dieser Reise öffnen', + 'wanderer.preview.newHint': + 'Beim Import wird der Trail als eigene Tour in diese Reise kopiert. Spätere Änderungen in wanderer lassen sie unverändert.', + 'wanderer.preview.unchanged': + 'Schon als „{name}“ in dieser Reise, importiert am {date} und seitdem in wanderer unverändert.', + 'wanderer.preview.changed': 'Am {date} als „{name}“ importiert. Seitdem in wanderer geändert: {parts}.', + 'wanderer.preview.editedLocally': 'Die Tour in dieser Reise wurde seitdem hier bearbeitet.', + 'wanderer.preview.newVersionHint': + 'Ein erneuter Import legt daneben eine neue Version an. Die Tour in der Reise bleibt, wie sie ist.', + 'wanderer.preview.change.name': 'Name', + 'wanderer.preview.change.description': 'Beschreibung', + 'wanderer.preview.change.route': 'Route', + 'wanderer.tourTitle': 'wanderer-Tour', + 'wanderer.import.done': '„{name}“ importiert', + 'wanderer.import.stale': 'Der Trail hat sich in wanderer geändert. Hier ist die aktuelle Version.', +}; +export default wanderer; diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 099a0ea99..b7b3f823f 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -704,6 +704,12 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Addon updated', 'admin.addons.toast.error': 'Failed to update addon', 'admin.addons.group.count': '{enabled} of {total} enabled', + 'admin.addons.subOptions': '{count} options', + 'admin.addons.subOptions.one': '{count} option', + 'admin.addons.subOptionsOn': '{count} on', + 'admin.addons.subOptionsOn.one': '{count} on', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': 'Users can connect their own wanderer instance and import its trails as tours', 'admin.addons.noAddons': 'No addons available', 'admin.weather.title': 'Weather Data', 'admin.weather.badge': 'Since March 24, 2026', diff --git a/shared/src/i18n/en/index.ts b/shared/src/i18n/en/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/en/index.ts +++ b/shared/src/i18n/en/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts index c7ee4a8b0..5263e4a58 100644 --- a/shared/src/i18n/en/tours.ts +++ b/shared/src/i18n/en/tours.ts @@ -22,8 +22,60 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Imported with caution — elevation/metrics incomplete', 'tours.import.success': '{count} tours imported from GPX', 'tours.import.success.one': '{count} tour imported from GPX', - 'tours.import.noTrack': 'No track or route found in that GPX file', - 'tours.import.error': 'Could not import GPX file', + 'tours.import.noTrackIn': 'No track or route found in {name}', + 'tours.import.errorIn': 'Could not import {name}', + 'tours.import.button': 'Import GPX', + 'tours.import.hint': 'Import GPX files as tours of this trip', + 'tours.import.unsupported': 'Only GPX files can be imported as tours.', + 'tours.import.skipped': '{count} tracks were already in this trip and were skipped', + 'tours.import.skipped.one': '{count} track was already in this trip and was skipped', + 'tours.import.allSkipped': 'Everything in that file is already in this trip.', + 'tours.import.drop': 'Drop GPX files to import them', + 'tours.import.dropHint': 'Every track or route in a file becomes a tour of this trip.', + 'tours.import.dropNoPermission': 'You cannot add tours to this trip.', + 'tours.import.importing': 'Importing GPX…', + 'tours.days.label': 'Trip days', + 'tours.days.none': 'Not on any day of the trip yet.', + 'tours.days.short': 'Days {days}', + 'tours.days.short.one': 'Day {days}', + 'tours.alreadyOnDay': 'This tour is already on that day.', + 'tours.conversion.title': 'Editable version', + 'tours.conversion.intro': + 'An import is a recorded track. Plan an editable version: TREK takes control points from it, routes them anew and shows how close that comes. Nothing changes until you choose.', + 'tours.conversion.replace': 'Replace the import', + 'tours.conversion.replaceHint': + 'Plans this tour over in place: its days and notes stay, and the recorded track is kept so you can go back to it.', + 'tours.conversion.adoptHint': 'Plans a new tour beside the import, which stays as it is.', + 'tours.conversion.replacing': + 'Opened in the editor. Save it to replace the import; the recorded track stays restorable.', + 'tours.recorded.title': 'Recorded track', + 'tours.recorded.hint': 'This tour was planned over an import. The track it was recorded with is kept.', + 'tours.recorded.restore': 'Restore recorded track', + 'tours.recorded.confirmTitle': 'Restore the recorded track?', + 'tours.recorded.confirmBody': + 'The waypoints and the routed line are dropped, and the tour shows the track it was recorded with again. Its days and notes stay.', + 'tours.recorded.restored': 'Recorded track restored', + 'tours.recorded.failed': 'The recorded track could not be restored.', + 'tours.conversion.analyze': 'Plan an editable version', + 'tours.conversion.analyzing': 'Routing through {count} control points…', + 'tours.conversion.analyzing.one': 'Routing through {count} control point…', + 'tours.conversion.failedTrack': 'This track is too short to plan a tour from.', + 'tours.conversion.failedRouting': 'No route was found through the control points.', + 'tours.conversion.controlPoints': 'Control points', + 'tours.conversion.withinCorridor': 'Within {m} m of the track', + 'tours.conversion.importLength': 'Import', + 'tours.conversion.suggestionLength': 'Suggestion', + 'tours.conversion.maxDeviation': 'Largest deviation', + 'tours.conversion.legendImport': 'Import', + 'tours.conversion.legendSuggestion': 'Suggestion', + 'tours.conversion.close': 'The suggestion follows the track closely. It is routed anew, so it is not the same line.', + 'tours.conversion.different': + 'The suggestion leaves the track over longer stretches. Check it on the map before you plan with it.', + 'tours.conversion.adopt': 'Plan as new tour', + 'tours.conversion.discard': 'Discard', + 'tours.conversion.adopted': 'Opened as a new tour. Save it to keep it; the import stays as it is.', + 'tours.derivedFrom': 'Planned from {name}', + 'tours.derivedFromHint': 'Routed anew from an import, so it can differ from the recorded track.', 'tours.delete.confirmBody': 'Delete this Tour permanently? Its route, editable waypoints and day assignments will be removed. This action cannot be undone.', 'tours.delete.bulkConfirmBody': @@ -37,9 +89,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Maximum altitude', 'tours.detail.ascent': 'Ascent', 'tours.detail.descent': 'Descent', - 'tours.detail.difficulty': 'Difficulty', 'tours.detail.source': 'Source', - 'tours.detail.gpxReadOnly': 'GPX tours are view-only and cannot be edited in the planner yet.', 'tours.detail.navigationHint': 'Navigation opens the trailhead; it does not navigate along the route.', 'tours.planner.title': 'Plan a tour', 'tours.planner.newTour': 'Plan new tour', diff --git a/shared/src/i18n/en/wanderer.ts b/shared/src/i18n/en/wanderer.ts new file mode 100644 index 000000000..74a82769f --- /dev/null +++ b/shared/src/i18n/en/wanderer.ts @@ -0,0 +1,94 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Connected', + 'wanderer.notConnected': 'Not connected', + 'wanderer.intro': + 'Connect your own wanderer instance to import its trails as tours. The token stays on the server and is never shown again.', + 'wanderer.url': 'Instance URL', + 'wanderer.token': 'API token', + 'wanderer.tokenHint': 'In wanderer under Settings, Account, API tokens.', + 'wanderer.tokenInvalid': 'That is not a wanderer API token. A token starts with wanderer_key_.', + 'wanderer.allowInsecureTls': 'Allow a self-signed certificate', + 'wanderer.allowInsecureTlsHint': 'Only for an instance on your own network.', + 'wanderer.test.button': 'Test connection', + 'wanderer.test.ok': 'Connected.', + 'wanderer.test.okCount': 'Connected. You can import {count} of your trails.', + 'wanderer.test.okCount.one': 'Connected. You can import {count} of your trails.', + 'wanderer.disconnect': 'Disconnect', + 'wanderer.saved': 'wanderer connection saved', + 'wanderer.disconnected': 'wanderer connection removed', + 'wanderer.loadFailed': 'Could not load the wanderer connection', + 'wanderer.privateIp': + 'This address is in a private network. If TREK cannot reach it, the administrator has to allow internal networks.', + 'wanderer.error.disabled': 'Your admin has not enabled wanderer for Tours.', + 'wanderer.error.not_connected': 'wanderer is not connected yet.', + 'wanderer.error.invalid_url': 'That address is not a valid wanderer URL.', + 'wanderer.error.blocked_address': + 'TREK may not reach that address. For an instance on your own network the administrator has to set ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': 'wanderer could not be reached. Check the address and that the instance is running.', + 'wanderer.error.tls': 'The certificate of the instance could not be verified. For a self-signed one, allow it below.', + 'wanderer.error.unauthorized': 'wanderer did not accept the API token. It may have been revoked or may have expired.', + 'wanderer.error.forbidden': 'wanderer refused access to this trail.', + 'wanderer.error.not_found': 'wanderer no longer has this trail.', + 'wanderer.error.not_own_trail': 'Only your own wanderer trails can be imported.', + 'wanderer.error.invalid_response': + 'The answer did not come from wanderer. Check that the address points at wanderer itself.', + 'wanderer.error.too_large': 'The trail file is larger than 10 MB.', + 'wanderer.error.server_error': 'wanderer answered with an error. Try again later.', + 'wanderer.error.offline': 'You are offline. wanderer can only be searched online.', + 'wanderer.error.preview_stale': 'The trail changed in wanderer while you were looking at it.', + 'wanderer.error.already_imported': 'This trail is already in the trip.', + 'wanderer.error.no_track': 'This trail has no track or route to import.', + 'wanderer.error.unknown': 'Something went wrong with wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Your trails', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Import a trail from your wanderer instance', + 'wanderer.browser.backToList': 'Back to the results', + 'wanderer.browser.searchPlaceholder': 'Search trails', + 'wanderer.browser.areaOff': 'Search this map area', + 'wanderer.browser.areaOn': 'Searching this map area', + 'wanderer.browser.areaOnHint': 'Only trails that reach into the part of the map you see', + 'wanderer.browser.areaOffHint': 'Search everywhere again', + 'wanderer.browser.connectTitle': 'Connect wanderer', + 'wanderer.browser.connect': 'Connect in settings', + 'wanderer.browser.empty': 'No trails found', + 'wanderer.browser.emptyArea': 'No trails in this map area', + 'wanderer.browser.emptyHint': 'Try another word or move the map.', + 'wanderer.browser.count': '{count} trails', + 'wanderer.browser.count.one': '{count} trail', + 'wanderer.browser.more': 'Load more', + 'wanderer.browser.public': 'Public', + 'wanderer.browser.private': 'Private', + 'wanderer.browser.publicHint': 'Public in wanderer: everyone on your instance can see it there', + 'wanderer.browser.privateHint': 'Private in wanderer: only you can see it there', + 'wanderer.browser.inTrip': 'In this trip', + 'wanderer.browser.inTripHint': 'Already imported into this trip', + 'wanderer.browser.areaBadge': 'Map area', + 'wanderer.preview.loading': 'Reading the trail from wanderer…', + 'wanderer.preview.measured': 'From the GPX file', + 'wanderer.preview.points': 'Track points', + 'wanderer.preview.noElevation': 'The file has no elevation, so ascent and descent are unknown.', + 'wanderer.preview.openExternal': 'Open in wanderer', + 'wanderer.preview.import': 'Import into trip', + 'wanderer.preview.importNewVersion': 'Import as new version', + 'wanderer.preview.openSnapshot': 'Open the tour', + 'wanderer.preview.openSnapshotShort': 'Open the tour in this trip', + 'wanderer.preview.newHint': + 'Importing copies the trail into this trip as a tour of its own. Later changes in wanderer leave it as it is.', + 'wanderer.preview.unchanged': + 'Already in this trip as “{name}”, imported on {date}, and unchanged in wanderer since.', + 'wanderer.preview.changed': 'Imported on {date} as “{name}”. Changed in wanderer since: {parts}.', + 'wanderer.preview.editedLocally': 'The tour in this trip was edited here since.', + 'wanderer.preview.newVersionHint': + 'Importing again adds a new version beside it. The tour in the trip stays as it is.', + 'wanderer.preview.change.name': 'name', + 'wanderer.preview.change.description': 'description', + 'wanderer.preview.change.route': 'route', + 'wanderer.tourTitle': 'wanderer tour', + 'wanderer.import.done': 'Imported “{name}”', + 'wanderer.import.stale': 'The trail changed in wanderer. Here is the current version.', +}; +export default wanderer; diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index b5816107b..8baf72b64 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -642,6 +642,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Complemento actualizado', 'admin.addons.toast.error': 'No se pudo actualizar el complemento', 'admin.addons.group.count': '{enabled} de {total} activados', + 'admin.addons.subOptions': '{count} opciones', + 'admin.addons.subOptions.one': '{count} opción', + 'admin.addons.subOptionsOn': '{count} activas', + 'admin.addons.subOptionsOn.one': '{count} activa', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Los usuarios pueden conectar su propia instancia de wanderer e importar sus senderos como excursiones', 'admin.addons.noAddons': 'No hay complementos disponibles', 'admin.weather.title': 'Datos meteorológicos', 'admin.weather.badge': 'Desde el 24 de marzo de 2026', diff --git a/shared/src/i18n/es/index.ts b/shared/src/i18n/es/index.ts index f7d357419..f4cd23fd1 100644 --- a/shared/src/i18n/es/index.ts +++ b/shared/src/i18n/es/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts index 01e7eea6c..24306956e 100644 --- a/shared/src/i18n/es/tours.ts +++ b/shared/src/i18n/es/tours.ts @@ -22,8 +22,64 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Importada con reservas: altitud o métricas incompletas', 'tours.import.success': '{count} excursiones importadas desde GPX', 'tours.import.success.one': '{count} excursión importada desde GPX', - 'tours.import.noTrack': 'No se ha encontrado ningún track ni ruta en ese archivo GPX', - 'tours.import.error': 'No se ha podido importar el archivo GPX', + 'tours.import.noTrackIn': 'No se ha encontrado ningún track ni ruta en {name}', + 'tours.import.errorIn': 'No se ha podido importar {name}', + 'tours.import.button': 'Importar GPX', + 'tours.import.hint': 'Importa archivos GPX como excursiones de este viaje', + 'tours.import.unsupported': 'Solo se pueden importar archivos GPX como excursiones.', + 'tours.import.skipped': '{count} tracks ya estaban en este viaje y se han omitido', + 'tours.import.skipped.one': '{count} track ya estaba en este viaje y se ha omitido', + 'tours.import.allSkipped': 'Todo lo de ese archivo ya está en este viaje.', + 'tours.import.drop': 'Suelta archivos GPX para importarlos', + 'tours.import.dropHint': 'Cada track o ruta de un archivo se convierte en una excursión de este viaje.', + 'tours.import.dropNoPermission': 'No puedes añadir excursiones a este viaje.', + 'tours.import.importing': 'Importando GPX…', + 'tours.days.label': 'Días del viaje', + 'tours.days.none': 'Aún no está en ningún día del viaje.', + 'tours.days.short': 'Días {days}', + 'tours.days.short.one': 'Día {days}', + 'tours.alreadyOnDay': 'Esta excursión ya está en ese día.', + 'tours.conversion.title': 'Versión editable', + 'tours.conversion.intro': + 'Una importación es un track grabado. Planifica una versión editable: TREK toma de él puntos de control, calcula de nuevo la ruta por ellos y muestra cuánto se acerca. Nada cambia hasta que elijas.', + 'tours.conversion.replace': 'Reemplazar la importación', + 'tours.conversion.replaceHint': + 'Vuelve a planificar esta excursión en el mismo sitio: sus días y notas se mantienen, y el track grabado se conserva para que puedas volver a él.', + 'tours.conversion.adoptHint': 'Planifica una excursión nueva junto a la importación, que se queda como está.', + 'tours.conversion.replacing': + 'Abierta en el editor. Guárdala para reemplazar la importación; el track grabado se puede seguir restaurando.', + 'tours.recorded.title': 'Track grabado', + 'tours.recorded.hint': + 'Esta excursión se volvió a planificar sobre una importación. Se conserva el track con el que se grabó.', + 'tours.recorded.restore': 'Restaurar el track grabado', + 'tours.recorded.confirmTitle': '¿Restaurar el track grabado?', + 'tours.recorded.confirmBody': + 'Los puntos de paso y la línea calculada se descartan, y la excursión vuelve a mostrar el track con el que se grabó. Sus días y notas se mantienen.', + 'tours.recorded.restored': 'Track grabado restaurado', + 'tours.recorded.failed': 'No se ha podido restaurar el track grabado.', + 'tours.conversion.analyze': 'Planificar una versión editable', + 'tours.conversion.analyzing': 'Calculando la ruta por {count} puntos de control…', + 'tours.conversion.analyzing.one': 'Calculando la ruta por {count} punto de control…', + 'tours.conversion.failedTrack': 'Este track es demasiado corto para planificar una excursión a partir de él.', + 'tours.conversion.failedRouting': 'No se ha encontrado ninguna ruta por los puntos de control.', + 'tours.conversion.controlPoints': 'Puntos de control', + 'tours.conversion.withinCorridor': 'A menos de {m} m del track', + 'tours.conversion.importLength': 'Importación', + 'tours.conversion.suggestionLength': 'Propuesta', + 'tours.conversion.maxDeviation': 'Mayor desviación', + 'tours.conversion.legendImport': 'Importación', + 'tours.conversion.legendSuggestion': 'Propuesta', + 'tours.conversion.close': + 'La propuesta sigue de cerca el track. Su ruta se ha calculado de nuevo, así que no es la misma línea.', + 'tours.conversion.different': + 'La propuesta se aparta del track en tramos largos. Revísala en el mapa antes de planificar con ella.', + 'tours.conversion.adopt': 'Planificar como excursión nueva', + 'tours.conversion.discard': 'Descartar', + 'tours.conversion.adopted': + 'Abierta como excursión nueva. Guárdala para conservarla; la importación se queda como está.', + 'tours.derivedFrom': 'Planificada a partir de {name}', + 'tours.derivedFromHint': + 'Ruta calculada de nuevo a partir de una importación, así que puede diferir del track grabado.', 'tours.delete.confirmBody': '¿Eliminar esta ruta permanentemente? Se eliminarán el recorrido, los puntos editables y las asignaciones a días. Esta acción no se puede deshacer.', 'tours.delete.bulkConfirmBody': @@ -38,9 +94,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Altitud máxima', 'tours.detail.ascent': 'Desnivel positivo', 'tours.detail.descent': 'Desnivel negativo', - 'tours.detail.difficulty': 'Dificultad', 'tours.detail.source': 'Origen', - 'tours.detail.gpxReadOnly': 'Las excursiones GPX son de solo lectura y aún no se pueden editar en el planificador.', 'tours.detail.navigationHint': 'La navegación abre el punto de inicio; no guía a lo largo de la ruta.', 'tours.planner.title': 'Planificar una excursión', 'tours.planner.newTour': 'Planificar nueva excursión', diff --git a/shared/src/i18n/es/wanderer.ts b/shared/src/i18n/es/wanderer.ts new file mode 100644 index 000000000..31e22ecf1 --- /dev/null +++ b/shared/src/i18n/es/wanderer.ts @@ -0,0 +1,96 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Conectado', + 'wanderer.notConnected': 'Sin conectar', + 'wanderer.intro': + 'Conecta tu propia instancia de wanderer para importar sus senderos como excursiones. El token se queda en el servidor y no se vuelve a mostrar.', + 'wanderer.url': 'Dirección de la instancia', + 'wanderer.token': 'Token de API', + 'wanderer.tokenHint': 'En wanderer, en Ajustes, Cuenta, Tokens de API.', + 'wanderer.tokenInvalid': 'Eso no es un token de API de wanderer. Un token empieza por wanderer_key_.', + 'wanderer.allowInsecureTls': 'Permitir certificado autofirmado', + 'wanderer.allowInsecureTlsHint': 'Solo para una instancia en tu propia red.', + 'wanderer.test.button': 'Probar conexión', + 'wanderer.test.ok': 'Conectado.', + 'wanderer.test.okCount': 'Conectado. Puedes importar {count} de tus senderos.', + 'wanderer.test.okCount.one': 'Conectado. Puedes importar {count} de tus senderos.', + 'wanderer.disconnect': 'Desconectar', + 'wanderer.saved': 'Conexión con wanderer guardada', + 'wanderer.disconnected': 'Conexión con wanderer eliminada', + 'wanderer.loadFailed': 'No se ha podido cargar la conexión con wanderer', + 'wanderer.privateIp': + 'Esta dirección está en una red privada. Si TREK no puede alcanzarla, el administrador tiene que permitir las redes internas.', + 'wanderer.error.disabled': 'Tu administrador no ha activado wanderer para las excursiones.', + 'wanderer.error.not_connected': 'wanderer aún no está conectado.', + 'wanderer.error.invalid_url': 'Esa dirección no es una URL de wanderer válida.', + 'wanderer.error.blocked_address': + 'TREK no puede acceder a esa dirección. Para una instancia en tu propia red, el administrador tiene que establecer ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': + 'No se ha podido contactar con wanderer. Comprueba la dirección y que la instancia esté en marcha.', + 'wanderer.error.tls': 'No se ha podido verificar el certificado de la instancia. Si es autofirmado, permítelo abajo.', + 'wanderer.error.unauthorized': + 'wanderer no ha aceptado el token de API. Puede que se haya revocado o que haya caducado.', + 'wanderer.error.forbidden': 'wanderer ha denegado el acceso a este sendero.', + 'wanderer.error.not_found': 'Este sendero ya no existe en wanderer.', + 'wanderer.error.not_own_trail': 'Solo se pueden importar tus propios senderos de wanderer.', + 'wanderer.error.invalid_response': + 'La respuesta no viene de wanderer. Comprueba que la dirección apunta al propio wanderer.', + 'wanderer.error.too_large': 'El archivo del sendero ocupa más de 10 MB.', + 'wanderer.error.server_error': 'wanderer ha respondido con un error. Inténtalo de nuevo más tarde.', + 'wanderer.error.offline': 'Estás sin conexión. Solo se puede buscar en wanderer con conexión.', + 'wanderer.error.preview_stale': 'El sendero ha cambiado en wanderer mientras lo estabas viendo.', + 'wanderer.error.already_imported': 'Este sendero ya está en el viaje.', + 'wanderer.error.no_track': 'Este sendero no tiene ningún track ni ruta que importar.', + 'wanderer.error.unknown': 'Algo ha fallado con wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Tus senderos', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Importa un sendero de tu instancia de wanderer', + 'wanderer.browser.backToList': 'Volver a los resultados', + 'wanderer.browser.searchPlaceholder': 'Buscar senderos', + 'wanderer.browser.areaOff': 'Buscar en esta zona del mapa', + 'wanderer.browser.areaOn': 'Buscando en esta zona del mapa', + 'wanderer.browser.areaOnHint': 'Solo senderos que entran en la parte del mapa que ves', + 'wanderer.browser.areaOffHint': 'Volver a buscar en todas partes', + 'wanderer.browser.connectTitle': 'Conectar wanderer', + 'wanderer.browser.connect': 'Conectar en los ajustes', + 'wanderer.browser.empty': 'No se han encontrado senderos', + 'wanderer.browser.emptyArea': 'No hay senderos en esta zona del mapa', + 'wanderer.browser.emptyHint': 'Prueba con otra palabra o mueve el mapa.', + 'wanderer.browser.count': '{count} senderos', + 'wanderer.browser.count.one': '{count} sendero', + 'wanderer.browser.more': 'Cargar más', + 'wanderer.browser.public': 'Público', + 'wanderer.browser.private': 'Privado', + 'wanderer.browser.publicHint': 'Público en wanderer: todos en tu instancia pueden verlo allí', + 'wanderer.browser.privateHint': 'Privado en wanderer: solo tú puedes verlo allí', + 'wanderer.browser.inTrip': 'En este viaje', + 'wanderer.browser.inTripHint': 'Ya importado en este viaje', + 'wanderer.browser.areaBadge': 'Zona del mapa', + 'wanderer.preview.loading': 'Leyendo el sendero de wanderer…', + 'wanderer.preview.measured': 'Según el archivo GPX', + 'wanderer.preview.points': 'Puntos del track', + 'wanderer.preview.noElevation': 'El archivo no tiene altitud, así que el ascenso y el descenso son desconocidos.', + 'wanderer.preview.openExternal': 'Abrir en wanderer', + 'wanderer.preview.import': 'Importar al viaje', + 'wanderer.preview.importNewVersion': 'Importar como versión nueva', + 'wanderer.preview.openSnapshot': 'Abrir la excursión', + 'wanderer.preview.openSnapshotShort': 'Abrir la excursión en este viaje', + 'wanderer.preview.newHint': + 'Al importar, el sendero se copia en este viaje como una excursión propia. Los cambios posteriores en wanderer no la modifican.', + 'wanderer.preview.unchanged': + 'Ya está en este viaje como «{name}», importado el {date} y sin cambios en wanderer desde entonces.', + 'wanderer.preview.changed': 'Importado el {date} como «{name}». Cambiado en wanderer desde entonces: {parts}.', + 'wanderer.preview.editedLocally': 'La excursión de este viaje se ha editado aquí desde entonces.', + 'wanderer.preview.newVersionHint': + 'Si vuelves a importar, se añade una versión nueva al lado. La excursión del viaje se queda como está.', + 'wanderer.preview.change.name': 'nombre', + 'wanderer.preview.change.description': 'descripción', + 'wanderer.preview.change.route': 'ruta', + 'wanderer.tourTitle': 'Excursión de wanderer', + 'wanderer.import.done': '«{name}» importado', + 'wanderer.import.stale': 'El sendero ha cambiado en wanderer. Esta es la versión actual.', +}; +export default wanderer; diff --git a/shared/src/i18n/et/admin.ts b/shared/src/i18n/et/admin.ts index ad1f406d8..23505bf29 100644 --- a/shared/src/i18n/et/admin.ts +++ b/shared/src/i18n/et/admin.ts @@ -723,6 +723,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Lisamoodul uuendatud', 'admin.addons.toast.error': 'Lisamooduli uuendamine ebaõnnestus', 'admin.addons.group.count': 'Lubatud {enabled}/{total}', + 'admin.addons.subOptions': '{count} valikut', + 'admin.addons.subOptions.one': '{count} valik', + 'admin.addons.subOptionsOn': '{count} lubatud', + 'admin.addons.subOptionsOn.one': '{count} lubatud', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Kasutajad saavad ühendada oma wanderer-serveri ja importida selle matkarajad retkedena', 'admin.addons.noAddons': 'Lisamooduleid pole saadaval', 'admin.weather.title': 'Ilmaandmed', 'admin.weather.badge': 'Alates 24. märtsist 2026', diff --git a/shared/src/i18n/et/index.ts b/shared/src/i18n/et/index.ts index 49ed7a462..ec191b6db 100644 --- a/shared/src/i18n/et/index.ts +++ b/shared/src/i18n/et/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -70,6 +71,7 @@ const locale = { ...atlas, ...trip, ...tours, + ...wanderer, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts index 39cbebe3a..9ff33b32e 100644 --- a/shared/src/i18n/et/tours.ts +++ b/shared/src/i18n/et/tours.ts @@ -22,8 +22,61 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Imporditud hoiatusega — kõrgusandmed või mõõdikud on puudulikud', 'tours.import.success': 'GPX-failist imporditi {count} retke', 'tours.import.success.one': 'GPX-failist imporditi {count} retk', - 'tours.import.noTrack': 'Sellest GPX-failist ei leitud rada ega marsruuti', - 'tours.import.error': 'GPX-faili importimine ebaõnnestus', + 'tours.import.noTrackIn': 'Failist {name} ei leitud rada ega marsruuti', + 'tours.import.errorIn': 'Faili {name} importimine ebaõnnestus', + 'tours.import.button': 'Impordi GPX', + 'tours.import.hint': 'Impordi GPX-failid selle reisi retkedena', + 'tours.import.unsupported': 'Retkedena saab importida ainult GPX-faile.', + 'tours.import.skipped': '{count} rada olid juba selles reisis ja jäeti vahele', + 'tours.import.skipped.one': '{count} rada oli juba selles reisis ja jäeti vahele', + 'tours.import.allSkipped': 'Kõik selle faili sisu on juba selles reisis.', + 'tours.import.drop': 'Lohista GPX-failid siia, et need importida', + 'tours.import.dropHint': 'Faili iga rajast või marsruudist saab selle reisi retk.', + 'tours.import.dropNoPermission': 'Sa ei saa sellele reisile retki lisada.', + 'tours.import.importing': 'GPX-i importimine…', + 'tours.days.label': 'Reisipäevad', + 'tours.days.none': 'Pole veel ühelegi reisipäevale lisatud.', + 'tours.days.short': 'Päevad {days}', + 'tours.days.short.one': 'Päev {days}', + 'tours.alreadyOnDay': 'See retk on sellel päeval juba olemas.', + 'tours.conversion.title': 'Muudetav versioon', + 'tours.conversion.intro': + 'Import on salvestatud rada. Planeeri muudetav versioon: TREK võtab sellest kontrollpunktid, arvutab nende kaudu marsruudi uuesti ja näitab, kui lähedale see jõuab. Midagi ei muutu enne, kui sa valid.', + 'tours.conversion.replace': 'Asenda import', + 'tours.conversion.replaceHint': + 'Planeerib selle retke samas kohas uuesti: selle päevad ja märkmed jäävad alles ning salvestatud rada säilitatakse, et saaksid selle juurde tagasi minna.', + 'tours.conversion.adoptHint': 'Planeerib impordi kõrvale uue retke, import jääb muutmata.', + 'tours.conversion.replacing': + 'Avatud redaktoris. Salvesta see, et import asendada; salvestatud rada jääb taastatavaks.', + 'tours.recorded.title': 'Salvestatud rada', + 'tours.recorded.hint': 'See retk on impordi põhjal uuesti planeeritud. Rada, millega see salvestati, on säilitatud.', + 'tours.recorded.restore': 'Taasta salvestatud rada', + 'tours.recorded.confirmTitle': 'Kas taastada salvestatud rada?', + 'tours.recorded.confirmBody': + 'Teekonnapunktid ja arvutatud joon eemaldatakse ning retk näitab taas rada, millega see salvestati. Selle päevad ja märkmed jäävad alles.', + 'tours.recorded.restored': 'Salvestatud rada taastatud', + 'tours.recorded.failed': 'Salvestatud rada ei õnnestunud taastada.', + 'tours.conversion.analyze': 'Planeeri muudetav versioon', + 'tours.conversion.analyzing': 'Marsruudi arvutamine {count} kontrollpunkti kaudu…', + 'tours.conversion.analyzing.one': 'Marsruudi arvutamine {count} kontrollpunkti kaudu…', + 'tours.conversion.failedTrack': 'See rada on retke planeerimiseks liiga lühike.', + 'tours.conversion.failedRouting': 'Kontrollpunktide kaudu ei leitud marsruuti.', + 'tours.conversion.controlPoints': 'Kontrollpunktid', + 'tours.conversion.withinCorridor': 'Rajast kuni {m} m kaugusel', + 'tours.conversion.importLength': 'Import', // same-as-en + 'tours.conversion.suggestionLength': 'Ettepanek', + 'tours.conversion.maxDeviation': 'Suurim kõrvalekalle', + 'tours.conversion.legendImport': 'Import', // same-as-en + 'tours.conversion.legendSuggestion': 'Ettepanek', + 'tours.conversion.close': + 'Ettepanek järgib rada tihedalt. Marsruut on arvutatud uuesti, seega pole see täpselt sama joon.', + 'tours.conversion.different': + 'Ettepanek kaldub pikematel lõikudel rajast kõrvale. Kontrolli seda kaardil, enne kui sellega planeerid.', + 'tours.conversion.adopt': 'Planeeri uue retkena', + 'tours.conversion.discard': 'Loobu', + 'tours.conversion.adopted': 'Avatud uue retkena. Salvesta see, et see alles jääks; import jääb muutmata.', + 'tours.derivedFrom': 'Planeeritud allikast {name}', + 'tours.derivedFromHint': 'Impordist uuesti arvutatud, seega võib see salvestatud rajast erineda.', 'tours.delete.confirmBody': 'Kas kustutada see matk jäädavalt? Selle marsruut, muudetavad vahepunktid ja päevadele määramised eemaldatakse. Seda toimingut ei saa tagasi võtta.', 'tours.delete.bulkConfirmBody': @@ -37,9 +90,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Kõrgeim kõrgus', 'tours.detail.ascent': 'Tõus', 'tours.detail.descent': 'Laskumine', - 'tours.detail.difficulty': 'Raskusaste', 'tours.detail.source': 'Allikas', - 'tours.detail.gpxReadOnly': 'GPX-retki saab ainult vaadata; planeerijas neid veel muuta ei saa.', 'tours.detail.navigationHint': 'Navigeerimine avab raja alguspunkti, mitte juhiseid mööda marsruuti.', 'tours.planner.title': 'Planeeri retk', 'tours.planner.newTour': 'Planeeri uus retk', diff --git a/shared/src/i18n/et/wanderer.ts b/shared/src/i18n/et/wanderer.ts new file mode 100644 index 000000000..bbb47c367 --- /dev/null +++ b/shared/src/i18n/et/wanderer.ts @@ -0,0 +1,95 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Ühendatud', + 'wanderer.notConnected': 'Ühendamata', + 'wanderer.intro': + 'Ühenda oma wanderer-server, et importida selle matkarajad retkedena. Tõend jääb serverisse ja seda ei näidata enam kunagi.', + 'wanderer.url': 'Serveri URL', + 'wanderer.token': 'API-tõend', + 'wanderer.tokenHint': 'Rakenduses wanderer jaotises Seaded, Konto, API-tõendid.', + 'wanderer.tokenInvalid': 'See pole wanderer-rakenduse API-tõend. Tõend algab eesliitega wanderer_key_.', + 'wanderer.allowInsecureTls': 'Luba iseallkirjastatud sertifikaat', + 'wanderer.allowInsecureTlsHint': 'Ainult sinu enda võrgus oleva serveri jaoks.', + 'wanderer.test.button': 'Testi ühendust', + 'wanderer.test.ok': 'Ühendatud.', + 'wanderer.test.okCount': 'Ühendatud. Saad importida {count} oma matkarada.', + 'wanderer.test.okCount.one': 'Ühendatud. Saad importida {count} oma matkaraja.', + 'wanderer.disconnect': 'Katkesta ühendus', + 'wanderer.saved': 'wanderer-ühendus salvestatud', + 'wanderer.disconnected': 'wanderer-ühendus eemaldatud', + 'wanderer.loadFailed': 'wanderer-ühenduse laadimine ebaõnnestus', + 'wanderer.privateIp': + 'See aadress on privaatvõrgus. Kui TREK ei saa sellega ühendust, peab administraator lubama sisevõrgud.', + 'wanderer.error.disabled': 'Administraator pole wanderer-rakendust retkede jaoks lubanud.', + 'wanderer.error.not_connected': 'wanderer pole veel ühendatud.', + 'wanderer.error.invalid_url': 'See aadress pole kehtiv wanderer-URL.', + 'wanderer.error.blocked_address': + 'TREK ei tohi selle aadressiga ühendust võtta. Sinu enda võrgus oleva serveri jaoks peab administraator määrama ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': 'wanderer pole kättesaadav. Kontrolli aadressi ja et server töötab.', + 'wanderer.error.tls': + 'Serveri sertifikaati ei õnnestunud kontrollida. Iseallkirjastatud sertifikaadi korral luba see allpool.', + 'wanderer.error.unauthorized': 'wanderer ei aktsepteerinud API-tõendit. See võib olla tühistatud või aegunud.', + 'wanderer.error.forbidden': 'wanderer keelas juurdepääsu sellele matkarajale.', + 'wanderer.error.not_found': 'Seda matkarada pole wanderer-rakenduses enam olemas.', + 'wanderer.error.not_own_trail': 'Importida saab ainult oma wanderer-matkaradu.', + 'wanderer.error.invalid_response': + 'Vastus ei tulnud wanderer-rakendusest. Kontrolli, et aadress viitab otse wanderer-rakendusele.', + 'wanderer.error.too_large': 'Matkaraja fail on suurem kui 10 MB.', + 'wanderer.error.server_error': 'wanderer vastas veaga. Proovi hiljem uuesti.', + 'wanderer.error.offline': 'Oled võrguühenduseta. wanderer-rakendusest saab otsida ainult võrgus olles.', + 'wanderer.error.preview_stale': 'Matkarada muutus wanderer-rakenduses sel ajal, kui seda vaatasid.', + 'wanderer.error.already_imported': 'See matkarada on juba reisis.', + 'wanderer.error.no_track': 'Sellel matkarajal pole imporditavat rada ega marsruuti.', + 'wanderer.error.unknown': 'wanderer-rakendusega läks midagi valesti.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Sinu matkarajad', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Impordi matkarada oma wanderer-serverist', + 'wanderer.browser.backToList': 'Tagasi tulemuste juurde', + 'wanderer.browser.searchPlaceholder': 'Otsi matkaradu', + 'wanderer.browser.areaOff': 'Otsi sellelt kaardialalt', + 'wanderer.browser.areaOn': 'Otsitakse sellelt kaardialalt', + 'wanderer.browser.areaOnHint': 'Ainult matkarajad, mis ulatuvad nähtavasse kaardiossa', + 'wanderer.browser.areaOffHint': 'Otsi jälle kõikjalt', + 'wanderer.browser.connectTitle': 'Ühenda wanderer', + 'wanderer.browser.connect': 'Ühenda seadetes', + 'wanderer.browser.empty': 'Matkaradu ei leitud', + 'wanderer.browser.emptyArea': 'Sellel kaardialal pole matkaradu', + 'wanderer.browser.emptyHint': 'Proovi teist sõna või liiguta kaarti.', + 'wanderer.browser.count': '{count} matkarada', + 'wanderer.browser.count.one': '{count} matkarada', + 'wanderer.browser.more': 'Laadi veel', + 'wanderer.browser.public': 'Avalik', + 'wanderer.browser.private': 'Privaatne', + 'wanderer.browser.publicHint': 'Avalik wanderer-rakenduses: seal näevad seda kõik sinu serveri kasutajad', + 'wanderer.browser.privateHint': 'Privaatne wanderer-rakenduses: seal näed seda ainult sina', + 'wanderer.browser.inTrip': 'Selles reisis', + 'wanderer.browser.inTripHint': 'Juba sellesse reisi imporditud', + 'wanderer.browser.areaBadge': 'Kaardiala', + 'wanderer.preview.loading': 'Matkaraja lugemine wanderer-rakendusest…', + 'wanderer.preview.measured': 'GPX-failist', + 'wanderer.preview.points': 'Rajapunktid', + 'wanderer.preview.noElevation': 'Failis pole kõrgusandmeid, seega tõus ja laskumine on teadmata.', + 'wanderer.preview.openExternal': 'Ava wanderer-rakenduses', + 'wanderer.preview.import': 'Impordi reisi', + 'wanderer.preview.importNewVersion': 'Impordi uue versioonina', + 'wanderer.preview.openSnapshot': 'Ava retk', + 'wanderer.preview.openSnapshotShort': 'Ava retk selles reisis', + 'wanderer.preview.newHint': + 'Importimine kopeerib matkaraja sellesse reisi eraldi retkena. Hilisemad muudatused wanderer-rakenduses seda ei mõjuta.', + 'wanderer.preview.unchanged': + 'Juba selles reisis nimega „{name}“, imporditud {date} ja sellest ajast wanderer-rakenduses muutmata.', + 'wanderer.preview.changed': 'Imporditud {date} nimega „{name}“. Sellest ajast wanderer-rakenduses muudetud: {parts}.', + 'wanderer.preview.editedLocally': 'Selle reisi retke on sellest ajast siin muudetud.', + 'wanderer.preview.newVersionHint': + 'Uuesti importimine lisab selle kõrvale uue versiooni. Reisis olev retk jääb muutmata.', + 'wanderer.preview.change.name': 'nimi', + 'wanderer.preview.change.description': 'kirjeldus', + 'wanderer.preview.change.route': 'marsruut', + 'wanderer.tourTitle': 'wanderer-retk', + 'wanderer.import.done': 'Imporditud „{name}“', + 'wanderer.import.stale': 'Matkarada muutus wanderer-rakenduses. Siin on praegune versioon.', +}; +export default wanderer; diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index b2a121cd1..f4d0581e3 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -664,6 +664,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Extension mise à jour', 'admin.addons.toast.error': "Échec de la mise à jour de l'extension", 'admin.addons.group.count': '{enabled} sur {total} activés', + 'admin.addons.subOptions': '{count} options', // same-as-en + 'admin.addons.subOptions.one': '{count} option', // same-as-en + 'admin.addons.subOptionsOn': '{count} activées', + 'admin.addons.subOptionsOn.one': '{count} activée', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Les utilisateurs peuvent connecter leur propre instance wanderer et importer ses sentiers comme sorties', 'admin.addons.noAddons': 'Aucune extension disponible', 'admin.weather.title': 'Données météo', 'admin.weather.badge': 'Depuis le 24 mars 2026', diff --git a/shared/src/i18n/fr/index.ts b/shared/src/i18n/fr/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/fr/index.ts +++ b/shared/src/i18n/fr/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts index 27b2680a5..e347c934a 100644 --- a/shared/src/i18n/fr/tours.ts +++ b/shared/src/i18n/fr/tours.ts @@ -22,8 +22,63 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Importée avec réserves : altitude ou mesures incomplètes', 'tours.import.success': '{count} sorties importées depuis GPX', 'tours.import.success.one': '{count} sortie importée depuis GPX', - 'tours.import.noTrack': 'Aucune trace ni aucun itinéraire trouvé dans ce fichier GPX', - 'tours.import.error': "Impossible d'importer le fichier GPX", + 'tours.import.noTrackIn': 'Aucune trace ni aucun itinéraire trouvé dans {name}', + 'tours.import.errorIn': "Impossible d'importer {name}", + 'tours.import.button': 'Importer un GPX', + 'tours.import.hint': 'Importer des fichiers GPX comme sorties de ce voyage', + 'tours.import.unsupported': 'Seuls les fichiers GPX peuvent être importés comme sorties.', + 'tours.import.skipped': '{count} traces étaient déjà dans ce voyage et ont été ignorées', + 'tours.import.skipped.one': '{count} trace était déjà dans ce voyage et a été ignorée', + 'tours.import.allSkipped': 'Tout le contenu de ce fichier est déjà dans ce voyage.', + 'tours.import.drop': 'Déposez des fichiers GPX pour les importer', + 'tours.import.dropHint': "Chaque trace ou itinéraire d'un fichier devient une sortie de ce voyage.", + 'tours.import.dropNoPermission': 'Vous ne pouvez pas ajouter de sorties à ce voyage.', + 'tours.import.importing': 'Import du GPX…', + 'tours.days.label': 'Jours du voyage', + 'tours.days.none': "Sur aucun jour du voyage pour l'instant.", + 'tours.days.short': 'Jours {days}', + 'tours.days.short.one': 'Jour {days}', + 'tours.alreadyOnDay': 'Cette sortie est déjà sur ce jour.', + 'tours.conversion.title': 'Version modifiable', + 'tours.conversion.intro': + "Un import est une trace enregistrée. Planifiez une version modifiable : TREK en extrait des points de contrôle, recalcule l'itinéraire et montre à quel point le résultat s'en approche. Rien ne change tant que vous n'avez pas choisi.", + 'tours.conversion.replace': "Remplacer l'import", + 'tours.conversion.replaceHint': + 'Replanifie cette sortie sur place : ses jours et ses notes sont conservés, et la trace enregistrée est gardée pour que vous puissiez y revenir.', + 'tours.conversion.adoptHint': "Planifie une nouvelle sortie à côté de l'import, qui reste tel quel.", + 'tours.conversion.replacing': + "Ouverte dans l'éditeur. Enregistrez-la pour remplacer l'import ; la trace enregistrée reste restaurable.", + 'tours.recorded.title': 'Trace enregistrée', + 'tours.recorded.hint': + "Cette sortie a été replanifiée à partir d'un import. La trace avec laquelle elle a été enregistrée est conservée.", + 'tours.recorded.restore': 'Restaurer la trace enregistrée', + 'tours.recorded.confirmTitle': 'Restaurer la trace enregistrée ?', + 'tours.recorded.confirmBody': + 'Les points de passage et la ligne calculée sont supprimés, et la sortie affiche de nouveau la trace avec laquelle elle a été enregistrée. Ses jours et ses notes sont conservés.', + 'tours.recorded.restored': 'Trace enregistrée restaurée', + 'tours.recorded.failed': "La trace enregistrée n'a pas pu être restaurée.", + 'tours.conversion.analyze': 'Planifier une version modifiable', + 'tours.conversion.analyzing': "Calcul de l'itinéraire par {count} points de contrôle…", + 'tours.conversion.analyzing.one': "Calcul de l'itinéraire par {count} point de contrôle…", + 'tours.conversion.failedTrack': 'Cette trace est trop courte pour en planifier une sortie.', + 'tours.conversion.failedRouting': "Aucun itinéraire n'a été trouvé par les points de contrôle.", + 'tours.conversion.controlPoints': 'Points de contrôle', + 'tours.conversion.withinCorridor': 'À moins de {m} m de la trace', + 'tours.conversion.importLength': 'Importée', + 'tours.conversion.suggestionLength': 'Proposition', + 'tours.conversion.maxDeviation': 'Écart maximal', + 'tours.conversion.legendImport': 'Importée', + 'tours.conversion.legendSuggestion': 'Proposition', + 'tours.conversion.close': + "La proposition suit la trace de près. Son itinéraire est recalculé, ce n'est donc pas la même ligne.", + 'tours.conversion.different': + "La proposition s'écarte de la trace sur de longues portions. Vérifiez-la sur la carte avant de planifier avec.", + 'tours.conversion.adopt': 'Planifier comme nouvelle sortie', + 'tours.conversion.discard': 'Abandonner', + 'tours.conversion.adopted': + "Ouverte comme nouvelle sortie. Enregistrez-la pour la conserver ; l'import reste tel quel.", + 'tours.derivedFrom': 'Planifiée à partir de {name}', + 'tours.derivedFromHint': "Itinéraire recalculé à partir d'un import, il peut donc différer de la trace enregistrée.", 'tours.delete.confirmBody': 'Supprimer définitivement cette randonnée ? Son itinéraire, ses points modifiables et ses affectations aux jours seront supprimés. Cette action est irréversible.', 'tours.delete.bulkConfirmBody': @@ -38,10 +93,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Altitude maximale', 'tours.detail.ascent': 'Dénivelé positif', 'tours.detail.descent': 'Dénivelé négatif', - 'tours.detail.difficulty': 'Difficulté', 'tours.detail.source': 'Source', - 'tours.detail.gpxReadOnly': - 'Les sorties GPX sont en lecture seule et ne peuvent pas encore être modifiées dans le planificateur.', 'tours.detail.navigationHint': "La navigation ouvre le point de départ du sentier ; elle ne vous guide pas le long de l'itinéraire.", 'tours.planner.title': 'Planifier une sortie', diff --git a/shared/src/i18n/fr/wanderer.ts b/shared/src/i18n/fr/wanderer.ts new file mode 100644 index 000000000..e3ea1dd8e --- /dev/null +++ b/shared/src/i18n/fr/wanderer.ts @@ -0,0 +1,96 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Connecté', + 'wanderer.notConnected': 'Non connecté', + 'wanderer.intro': + "Connectez votre propre instance wanderer pour importer ses sentiers comme sorties. Le jeton reste sur le serveur et n'est plus jamais affiché.", + 'wanderer.url': "Adresse de l'instance", + 'wanderer.token': 'Jeton API', + 'wanderer.tokenHint': 'Dans wanderer, sous Paramètres, Compte, Jetons API.', + 'wanderer.tokenInvalid': "Ce n'est pas un jeton API wanderer. Un jeton commence par wanderer_key_.", + 'wanderer.allowInsecureTls': 'Autoriser un certificat auto-signé', + 'wanderer.allowInsecureTlsHint': 'Uniquement pour une instance sur votre propre réseau.', + 'wanderer.test.button': 'Tester la connexion', + 'wanderer.test.ok': 'Connecté.', + 'wanderer.test.okCount': 'Connecté. Vous pouvez importer {count} de vos sentiers.', + 'wanderer.test.okCount.one': 'Connecté. Vous pouvez importer {count} de vos sentiers.', + 'wanderer.disconnect': 'Déconnecter', + 'wanderer.saved': 'Connexion wanderer enregistrée', + 'wanderer.disconnected': 'Connexion wanderer supprimée', + 'wanderer.loadFailed': 'Impossible de charger la connexion wanderer', + 'wanderer.privateIp': + "Cette adresse se trouve dans un réseau privé. Si TREK ne peut pas l'atteindre, l'administrateur doit autoriser les réseaux internes.", + 'wanderer.error.disabled': "Votre administrateur n'a pas activé wanderer pour les sorties.", + 'wanderer.error.not_connected': "wanderer n'est pas encore connecté.", + 'wanderer.error.invalid_url': "Cette adresse n'est pas une URL wanderer valide.", + 'wanderer.error.blocked_address': + "TREK n'a pas le droit d'atteindre cette adresse. Pour une instance sur votre propre réseau, l'administrateur doit définir ALLOW_INTERNAL_NETWORK=true.", + 'wanderer.error.unreachable': "wanderer est injoignable. Vérifiez l'adresse et que l'instance est bien en marche.", + 'wanderer.error.tls': + "Le certificat de l'instance n'a pas pu être vérifié. S'il est auto-signé, autorisez-le ci-dessous.", + 'wanderer.error.unauthorized': "wanderer n'a pas accepté le jeton API. Il a peut-être été révoqué ou a expiré.", + 'wanderer.error.forbidden': "wanderer a refusé l'accès à ce sentier.", + 'wanderer.error.not_found': "Ce sentier n'existe plus dans wanderer.", + 'wanderer.error.not_own_trail': 'Seuls vos propres sentiers wanderer peuvent être importés.', + 'wanderer.error.invalid_response': + "La réponse ne vient pas de wanderer. Vérifiez que l'adresse pointe bien vers wanderer lui-même.", + 'wanderer.error.too_large': 'Le fichier du sentier dépasse 10 MB.', + 'wanderer.error.server_error': 'wanderer a répondu par une erreur. Réessayez plus tard.', + 'wanderer.error.offline': "Vous êtes hors ligne. wanderer ne peut être consulté qu'en ligne.", + 'wanderer.error.preview_stale': 'Le sentier a changé dans wanderer pendant que vous le consultiez.', + 'wanderer.error.already_imported': 'Ce sentier est déjà dans le voyage.', + 'wanderer.error.no_track': "Ce sentier n'a aucune trace ni aucun itinéraire à importer.", + 'wanderer.error.unknown': 'Un problème est survenu avec wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Vos sentiers', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Importer un sentier depuis votre instance wanderer', + 'wanderer.browser.backToList': 'Retour aux résultats', + 'wanderer.browser.searchPlaceholder': 'Rechercher des sentiers', + 'wanderer.browser.areaOff': 'Rechercher dans cette zone de la carte', + 'wanderer.browser.areaOn': 'Recherche dans cette zone de la carte', + 'wanderer.browser.areaOnHint': 'Uniquement les sentiers qui passent par la partie visible de la carte', + 'wanderer.browser.areaOffHint': 'Rechercher de nouveau partout', + 'wanderer.browser.connectTitle': 'Connecter wanderer', + 'wanderer.browser.connect': 'Connecter dans les paramètres', + 'wanderer.browser.empty': 'Aucun sentier trouvé', + 'wanderer.browser.emptyArea': 'Aucun sentier dans cette zone de la carte', + 'wanderer.browser.emptyHint': 'Essayez un autre mot ou déplacez la carte.', + 'wanderer.browser.count': '{count} sentiers', + 'wanderer.browser.count.one': '{count} sentier', + 'wanderer.browser.more': 'Charger plus', + 'wanderer.browser.public': 'Public', // same-as-en + 'wanderer.browser.private': 'Privé', + 'wanderer.browser.publicHint': 'Public dans wanderer : tout le monde sur votre instance peut le voir', + 'wanderer.browser.privateHint': 'Privé dans wanderer : vous seul pouvez le voir', + 'wanderer.browser.inTrip': 'Dans ce voyage', + 'wanderer.browser.inTripHint': 'Déjà importé dans ce voyage', + 'wanderer.browser.areaBadge': 'Zone de la carte', + 'wanderer.preview.loading': 'Lecture du sentier depuis wanderer…', + 'wanderer.preview.measured': "D'après le fichier GPX", + 'wanderer.preview.points': 'Points de trace', + 'wanderer.preview.noElevation': + "Le fichier ne contient pas d'altitude, la montée et la descente sont donc inconnues.", + 'wanderer.preview.openExternal': 'Ouvrir dans wanderer', + 'wanderer.preview.import': 'Importer dans le voyage', + 'wanderer.preview.importNewVersion': 'Importer comme nouvelle version', + 'wanderer.preview.openSnapshot': 'Ouvrir la sortie', + 'wanderer.preview.openSnapshotShort': 'Ouvrir la sortie dans ce voyage', + 'wanderer.preview.newHint': + "L'import copie le sentier dans ce voyage comme sortie à part entière. Les modifications ultérieures dans wanderer ne la changent pas.", + 'wanderer.preview.unchanged': + 'Déjà dans ce voyage sous le nom « {name} », importé le {date} et inchangé dans wanderer depuis.', + 'wanderer.preview.changed': 'Importé le {date} sous le nom « {name} ». Modifié dans wanderer depuis : {parts}.', + 'wanderer.preview.editedLocally': 'La sortie de ce voyage a été modifiée ici depuis.', + 'wanderer.preview.newVersionHint': + 'Un nouvel import ajoute une nouvelle version à côté. La sortie du voyage reste telle quelle.', + 'wanderer.preview.change.name': 'nom', + 'wanderer.preview.change.description': 'description', // same-as-en + 'wanderer.preview.change.route': 'itinéraire', + 'wanderer.tourTitle': 'Sortie wanderer', + 'wanderer.import.done': '« {name} » importé', + 'wanderer.import.stale': 'Le sentier a changé dans wanderer. Voici la version actuelle.', +}; +export default wanderer; diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index a6a78736c..c77753a84 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -727,6 +727,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Το πρόσθετο ενημερώθηκε', 'admin.addons.toast.error': 'Αποτυχία ενημέρωσης προσθέτου', 'admin.addons.group.count': '{enabled} από {total} ενεργοποιημένα', + 'admin.addons.subOptions': '{count} επιλογές', + 'admin.addons.subOptions.one': '{count} επιλογή', + 'admin.addons.subOptionsOn': '{count} ενεργές', + 'admin.addons.subOptionsOn.one': '{count} ενεργή', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Οι χρήστες μπορούν να συνδέσουν τη δική τους εγκατάσταση wanderer και να εισαγάγουν τα μονοπάτια της ως εκδρομές', 'admin.addons.noAddons': 'Δεν υπάρχουν διαθέσιμα πρόσθετα', 'admin.weather.title': 'Δεδομένα Καιρού', 'admin.weather.badge': 'Από 24 Μαρτίου 2026', diff --git a/shared/src/i18n/gr/index.ts b/shared/src/i18n/gr/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/gr/index.ts +++ b/shared/src/i18n/gr/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts index 129d2434c..2bf9203ca 100644 --- a/shared/src/i18n/gr/tours.ts +++ b/shared/src/i18n/gr/tours.ts @@ -22,8 +22,63 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Εισαγωγή με επιφύλαξη – ελλιπή υψομετρικά δεδομένα ή μετρήσεις', 'tours.import.success': 'Εισήχθησαν {count} εκδρομές από GPX', 'tours.import.success.one': 'Εισήχθη {count} εκδρομή από GPX', - 'tours.import.noTrack': 'Δεν βρέθηκε ίχνος ή διαδρομή σε αυτό το αρχείο GPX', - 'tours.import.error': 'Δεν ήταν δυνατή η εισαγωγή του αρχείου GPX', + 'tours.import.noTrackIn': 'Δεν βρέθηκε ίχνος ή διαδρομή στο {name}', + 'tours.import.errorIn': 'Δεν ήταν δυνατή η εισαγωγή του {name}', + 'tours.import.button': 'Εισαγωγή GPX', + 'tours.import.hint': 'Εισαγωγή αρχείων GPX ως εκδρομών αυτού του ταξιδιού', + 'tours.import.unsupported': 'Μόνο αρχεία GPX μπορούν να εισαχθούν ως εκδρομές.', + 'tours.import.skipped': '{count} ίχνη υπήρχαν ήδη σε αυτό το ταξίδι και παραλείφθηκαν', + 'tours.import.skipped.one': '{count} ίχνος υπήρχε ήδη σε αυτό το ταξίδι και παραλείφθηκε', + 'tours.import.allSkipped': 'Όλο το περιεχόμενο του αρχείου υπάρχει ήδη σε αυτό το ταξίδι.', + 'tours.import.drop': 'Αφήστε εδώ αρχεία GPX για να τα εισαγάγετε', + 'tours.import.dropHint': 'Κάθε ίχνος ή διαδρομή ενός αρχείου γίνεται εκδρομή αυτού του ταξιδιού.', + 'tours.import.dropNoPermission': 'Δεν μπορείτε να προσθέσετε εκδρομές σε αυτό το ταξίδι.', + 'tours.import.importing': 'Εισαγωγή GPX…', + 'tours.days.label': 'Ημέρες ταξιδιού', + 'tours.days.none': 'Δεν έχει προστεθεί ακόμη σε καμία ημέρα του ταξιδιού.', + 'tours.days.short': 'Ημέρες {days}', + 'tours.days.short.one': 'Ημέρα {days}', + 'tours.alreadyOnDay': 'Αυτή η εκδρομή υπάρχει ήδη σε εκείνη την ημέρα.', + 'tours.conversion.title': 'Επεξεργάσιμη έκδοση', + 'tours.conversion.intro': + 'Μια εισαγωγή είναι ένα καταγεγραμμένο ίχνος. Σχεδιάστε μια επεξεργάσιμη έκδοση: το TREK παίρνει από αυτό σημεία ελέγχου, τα δρομολογεί εκ νέου και δείχνει πόσο κοντά φτάνει. Τίποτα δεν αλλάζει μέχρι να επιλέξετε.', + 'tours.conversion.replace': 'Αντικατάσταση της εισαγωγής', + 'tours.conversion.replaceHint': + 'Σχεδιάζει ξανά αυτή την εκδρομή στη θέση της: οι ημέρες και οι σημειώσεις της μένουν, και το καταγεγραμμένο ίχνος διατηρείται ώστε να μπορείτε να επιστρέψετε σε αυτό.', + 'tours.conversion.adoptHint': 'Σχεδιάζει μια νέα εκδρομή δίπλα στην εισαγωγή, η οποία μένει ως έχει.', + 'tours.conversion.replacing': + 'Άνοιξε στον επεξεργαστή. Αποθηκεύστε την για να αντικαταστήσετε την εισαγωγή· το καταγεγραμμένο ίχνος παραμένει επαναφέρσιμο.', + 'tours.recorded.title': 'Καταγεγραμμένο ίχνος', + 'tours.recorded.hint': + 'Αυτή η εκδρομή σχεδιάστηκε ξανά πάνω σε μια εισαγωγή. Το ίχνος με το οποίο καταγράφηκε διατηρείται.', + 'tours.recorded.restore': 'Επαναφορά καταγεγραμμένου ίχνους', + 'tours.recorded.confirmTitle': 'Επαναφορά του καταγεγραμμένου ίχνους;', + 'tours.recorded.confirmBody': + 'Τα σημεία διέλευσης και η δρομολογημένη γραμμή απορρίπτονται, και η εκδρομή δείχνει ξανά το ίχνος με το οποίο καταγράφηκε. Οι ημέρες και οι σημειώσεις της μένουν.', + 'tours.recorded.restored': 'Το καταγεγραμμένο ίχνος επανήλθε', + 'tours.recorded.failed': 'Δεν ήταν δυνατή η επαναφορά του καταγεγραμμένου ίχνους.', + 'tours.conversion.analyze': 'Σχεδιασμός επεξεργάσιμης έκδοσης', + 'tours.conversion.analyzing': 'Δρομολόγηση μέσω {count} σημείων ελέγχου…', + 'tours.conversion.analyzing.one': 'Δρομολόγηση μέσω {count} σημείου ελέγχου…', + 'tours.conversion.failedTrack': 'Αυτό το ίχνος είναι πολύ σύντομο για να σχεδιαστεί εκδρομή από αυτό.', + 'tours.conversion.failedRouting': 'Δεν βρέθηκε διαδρομή μέσα από τα σημεία ελέγχου.', + 'tours.conversion.controlPoints': 'Σημεία ελέγχου', + 'tours.conversion.withinCorridor': 'Εντός {m} μ. από το ίχνος', + 'tours.conversion.importLength': 'Εισαγωγή', + 'tours.conversion.suggestionLength': 'Πρόταση', + 'tours.conversion.maxDeviation': 'Μέγιστη απόκλιση', + 'tours.conversion.legendImport': 'Εισαγωγή', + 'tours.conversion.legendSuggestion': 'Πρόταση', + 'tours.conversion.close': + 'Η πρόταση ακολουθεί στενά το ίχνος. Έχει δρομολογηθεί εκ νέου, οπότε δεν είναι ακριβώς η ίδια γραμμή.', + 'tours.conversion.different': + 'Η πρόταση απομακρύνεται από το ίχνος σε μεγαλύτερα τμήματα. Ελέγξτε την στον χάρτη πριν σχεδιάσετε με αυτήν.', + 'tours.conversion.adopt': 'Σχεδιασμός ως νέα εκδρομή', + 'tours.conversion.discard': 'Απόρριψη', + 'tours.conversion.adopted': 'Άνοιξε ως νέα εκδρομή. Αποθηκεύστε την για να τη διατηρήσετε· η εισαγωγή μένει ως έχει.', + 'tours.derivedFrom': 'Σχεδιάστηκε από {name}', + 'tours.derivedFromHint': + 'Δρομολογήθηκε εκ νέου από μια εισαγωγή, οπότε μπορεί να διαφέρει από το καταγεγραμμένο ίχνος.', 'tours.delete.confirmBody': 'Να διαγραφεί οριστικά αυτή η διαδρομή; Η πορεία, τα επεξεργάσιμα σημεία και οι αναθέσεις σε ημέρες θα αφαιρεθούν. Αυτή η ενέργεια δεν αναιρείται.', 'tours.delete.bulkConfirmBody': @@ -38,10 +93,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Μέγιστο υψόμετρο', 'tours.detail.ascent': 'Υψομετρική άνοδος', 'tours.detail.descent': 'Υψομετρική κάθοδος', - 'tours.detail.difficulty': 'Δυσκολία', 'tours.detail.source': 'Πηγή', - 'tours.detail.gpxReadOnly': - 'Οι εκδρομές GPX είναι μόνο για προβολή και δεν μπορούν ακόμη να τροποποιηθούν στον σχεδιαστή.', 'tours.detail.navigationHint': 'Η πλοήγηση ανοίγει την αφετηρία του μονοπατιού· δεν σας καθοδηγεί κατά μήκος της διαδρομής.', 'tours.planner.title': 'Σχεδιασμός εκδρομής', diff --git a/shared/src/i18n/gr/wanderer.ts b/shared/src/i18n/gr/wanderer.ts new file mode 100644 index 000000000..209a07e9f --- /dev/null +++ b/shared/src/i18n/gr/wanderer.ts @@ -0,0 +1,97 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Συνδεδεμένο', + 'wanderer.notConnected': 'Μη συνδεδεμένο', + 'wanderer.intro': + 'Συνδέστε τη δική σας εγκατάσταση wanderer για να εισαγάγετε τα μονοπάτια της ως εκδρομές. Το διακριτικό παραμένει στον διακομιστή και δεν εμφανίζεται ποτέ ξανά.', + 'wanderer.url': 'URL εγκατάστασης', + 'wanderer.token': 'Διακριτικό API', + 'wanderer.tokenHint': 'Στο wanderer, στις Ρυθμίσεις, Λογαριασμός, Διακριτικά API.', + 'wanderer.tokenInvalid': 'Αυτό δεν είναι διακριτικό API του wanderer. Ένα διακριτικό ξεκινά με wanderer_key_.', + 'wanderer.allowInsecureTls': 'Να επιτρέπεται αυτοϋπογεγραμμένο πιστοποιητικό', + 'wanderer.allowInsecureTlsHint': 'Μόνο για εγκατάσταση στο δικό σας δίκτυο.', + 'wanderer.test.button': 'Δοκιμή σύνδεσης', + 'wanderer.test.ok': 'Συνδέθηκε.', + 'wanderer.test.okCount': 'Συνδέθηκε. Μπορείτε να εισαγάγετε {count} μονοπάτια σας.', + 'wanderer.test.okCount.one': 'Συνδέθηκε. Μπορείτε να εισαγάγετε {count} μονοπάτι σας.', + 'wanderer.disconnect': 'Αποσύνδεση', + 'wanderer.saved': 'Η σύνδεση με το wanderer αποθηκεύτηκε', + 'wanderer.disconnected': 'Η σύνδεση με το wanderer αφαιρέθηκε', + 'wanderer.loadFailed': 'Δεν ήταν δυνατή η φόρτωση της σύνδεσης με το wanderer', + 'wanderer.privateIp': + 'Αυτή η διεύθυνση βρίσκεται σε ιδιωτικό δίκτυο. Αν το TREK δεν μπορεί να τη φτάσει, ο διαχειριστής πρέπει να επιτρέψει τα εσωτερικά δίκτυα.', + 'wanderer.error.disabled': 'Ο διαχειριστής δεν έχει ενεργοποιήσει το wanderer για τις Εκδρομές.', + 'wanderer.error.not_connected': 'Το wanderer δεν έχει συνδεθεί ακόμη.', + 'wanderer.error.invalid_url': 'Αυτή η διεύθυνση δεν είναι έγκυρο URL του wanderer.', + 'wanderer.error.blocked_address': + 'Το TREK δεν επιτρέπεται να προσπελάσει αυτή τη διεύθυνση. Για εγκατάσταση στο δικό σας δίκτυο ο διαχειριστής πρέπει να ορίσει ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': + 'Δεν ήταν δυνατή η πρόσβαση στο wanderer. Ελέγξτε τη διεύθυνση και ότι η εγκατάσταση λειτουργεί.', + 'wanderer.error.tls': + 'Δεν ήταν δυνατή η επαλήθευση του πιστοποιητικού της εγκατάστασης. Αν είναι αυτοϋπογεγραμμένο, επιτρέψτε το παρακάτω.', + 'wanderer.error.unauthorized': + 'Το wanderer δεν αποδέχτηκε το διακριτικό API. Μπορεί να έχει ανακληθεί ή να έχει λήξει.', + 'wanderer.error.forbidden': 'Το wanderer αρνήθηκε την πρόσβαση σε αυτό το μονοπάτι.', + 'wanderer.error.not_found': 'Το wanderer δεν έχει πλέον αυτό το μονοπάτι.', + 'wanderer.error.not_own_trail': 'Μπορούν να εισαχθούν μόνο τα δικά σας μονοπάτια του wanderer.', + 'wanderer.error.invalid_response': + 'Η απάντηση δεν προήλθε από το wanderer. Ελέγξτε ότι η διεύθυνση οδηγεί στο ίδιο το wanderer.', + 'wanderer.error.too_large': 'Το αρχείο του μονοπατιού είναι μεγαλύτερο από 10 MB.', + 'wanderer.error.server_error': 'Το wanderer απάντησε με σφάλμα. Δοκιμάστε ξανά αργότερα.', + 'wanderer.error.offline': 'Είστε εκτός σύνδεσης. Η αναζήτηση στο wanderer γίνεται μόνο με σύνδεση.', + 'wanderer.error.preview_stale': 'Το μονοπάτι άλλαξε στο wanderer ενώ το βλέπατε.', + 'wanderer.error.already_imported': 'Αυτό το μονοπάτι υπάρχει ήδη στο ταξίδι.', + 'wanderer.error.no_track': 'Αυτό το μονοπάτι δεν έχει ίχνος ή διαδρομή για εισαγωγή.', + 'wanderer.error.unknown': 'Κάτι πήγε στραβά με το wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Τα μονοπάτια σας', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Εισαγωγή μονοπατιού από την εγκατάσταση wanderer σας', + 'wanderer.browser.backToList': 'Πίσω στα αποτελέσματα', + 'wanderer.browser.searchPlaceholder': 'Αναζήτηση μονοπατιών', + 'wanderer.browser.areaOff': 'Αναζήτηση σε αυτή την περιοχή του χάρτη', + 'wanderer.browser.areaOn': 'Γίνεται αναζήτηση σε αυτή την περιοχή του χάρτη', + 'wanderer.browser.areaOnHint': 'Μόνο μονοπάτια που φτάνουν στο τμήμα του χάρτη που βλέπετε', + 'wanderer.browser.areaOffHint': 'Αναζήτηση ξανά παντού', + 'wanderer.browser.connectTitle': 'Σύνδεση wanderer', + 'wanderer.browser.connect': 'Σύνδεση στις ρυθμίσεις', + 'wanderer.browser.empty': 'Δεν βρέθηκαν μονοπάτια', + 'wanderer.browser.emptyArea': 'Δεν υπάρχουν μονοπάτια σε αυτή την περιοχή του χάρτη', + 'wanderer.browser.emptyHint': 'Δοκιμάστε άλλη λέξη ή μετακινήστε τον χάρτη.', + 'wanderer.browser.count': '{count} μονοπάτια', + 'wanderer.browser.count.one': '{count} μονοπάτι', + 'wanderer.browser.more': 'Φόρτωση περισσότερων', + 'wanderer.browser.public': 'Δημόσιο', + 'wanderer.browser.private': 'Ιδιωτικό', + 'wanderer.browser.publicHint': 'Δημόσιο στο wanderer: όλοι στην εγκατάστασή σας μπορούν να το δουν εκεί', + 'wanderer.browser.privateHint': 'Ιδιωτικό στο wanderer: μόνο εσείς μπορείτε να το δείτε εκεί', + 'wanderer.browser.inTrip': 'Σε αυτό το ταξίδι', + 'wanderer.browser.inTripHint': 'Έχει ήδη εισαχθεί σε αυτό το ταξίδι', + 'wanderer.browser.areaBadge': 'Περιοχή χάρτη', + 'wanderer.preview.loading': 'Ανάγνωση του μονοπατιού από το wanderer…', + 'wanderer.preview.measured': 'Από το αρχείο GPX', + 'wanderer.preview.points': 'Σημεία ίχνους', + 'wanderer.preview.noElevation': 'Το αρχείο δεν έχει υψόμετρα, οπότε η άνοδος και η κάθοδος είναι άγνωστες.', + 'wanderer.preview.openExternal': 'Άνοιγμα στο wanderer', + 'wanderer.preview.import': 'Εισαγωγή στο ταξίδι', + 'wanderer.preview.importNewVersion': 'Εισαγωγή ως νέα έκδοση', + 'wanderer.preview.openSnapshot': 'Άνοιγμα της εκδρομής', + 'wanderer.preview.openSnapshotShort': 'Άνοιγμα της εκδρομής σε αυτό το ταξίδι', + 'wanderer.preview.newHint': + 'Η εισαγωγή αντιγράφει το μονοπάτι σε αυτό το ταξίδι ως ξεχωριστή εκδρομή. Μεταγενέστερες αλλαγές στο wanderer δεν την επηρεάζουν.', + 'wanderer.preview.unchanged': + 'Υπάρχει ήδη σε αυτό το ταξίδι ως «{name}», εισήχθη στις {date} και δεν έχει αλλάξει στο wanderer από τότε.', + 'wanderer.preview.changed': 'Εισήχθη στις {date} ως «{name}». Άλλαξε στο wanderer από τότε: {parts}.', + 'wanderer.preview.editedLocally': 'Η εκδρομή σε αυτό το ταξίδι έχει επεξεργαστεί εδώ από τότε.', + 'wanderer.preview.newVersionHint': + 'Μια νέα εισαγωγή προσθέτει μια νέα έκδοση δίπλα της. Η εκδρομή στο ταξίδι μένει ως έχει.', + 'wanderer.preview.change.name': 'όνομα', + 'wanderer.preview.change.description': 'περιγραφή', + 'wanderer.preview.change.route': 'διαδρομή', + 'wanderer.tourTitle': 'Εκδρομή wanderer', + 'wanderer.import.done': 'Εισήχθη το «{name}»', + 'wanderer.import.stale': 'Το μονοπάτι άλλαξε στο wanderer. Ορίστε η τρέχουσα έκδοση.', +}; +export default wanderer; diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index f9bf34df3..3246189ac 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -665,6 +665,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Bővítmény frissítve', 'admin.addons.toast.error': 'Nem sikerült frissíteni a bővítményt', 'admin.addons.group.count': '{total} közül {enabled} bekapcsolva', + 'admin.addons.subOptions': '{count} beállítás', + 'admin.addons.subOptions.one': '{count} beállítás', + 'admin.addons.subOptionsOn': '{count} bekapcsolva', + 'admin.addons.subOptionsOn.one': '{count} bekapcsolva', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'A felhasználók csatlakoztathatják saját wanderer-példányukat, és túraként importálhatják annak túraútvonalait', 'admin.addons.noAddons': 'Nincsenek elérhető bővítmények', 'admin.weather.title': 'Időjárás adatok', 'admin.weather.badge': '2026. március 24. óta', diff --git a/shared/src/i18n/hu/index.ts b/shared/src/i18n/hu/index.ts index 29eb22621..abeef31ea 100644 --- a/shared/src/i18n/hu/index.ts +++ b/shared/src/i18n/hu/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts index 7be7b0c5b..33dd8ee3a 100644 --- a/shared/src/i18n/hu/tours.ts +++ b/shared/src/i18n/hu/tours.ts @@ -22,8 +22,61 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Fenntartással importálva – hiányos magassági adatok vagy mutatók', 'tours.import.success': '{count} túra importálva GPX-ből', 'tours.import.success.one': '{count} túra importálva GPX-ből', - 'tours.import.noTrack': 'Ebben a GPX-fájlban nem található nyomvonal vagy útvonal', - 'tours.import.error': 'Nem sikerült importálni a GPX-fájlt', + 'tours.import.noTrackIn': 'Nem található nyomvonal vagy útvonal ebben: {name}', + 'tours.import.errorIn': 'Nem sikerült importálni: {name}', + 'tours.import.button': 'GPX importálása', + 'tours.import.hint': 'GPX-fájlok importálása az utazás túráiként', + 'tours.import.unsupported': 'Túraként csak GPX-fájlok importálhatók.', + 'tours.import.skipped': '{count} nyomvonal már szerepelt az utazásban, ezért kihagytuk', + 'tours.import.skipped.one': '{count} nyomvonal már szerepelt az utazásban, ezért kihagytuk', + 'tours.import.allSkipped': 'A fájl teljes tartalma már szerepel ebben az utazásban.', + 'tours.import.drop': 'Húzd ide a GPX-fájlokat az importáláshoz', + 'tours.import.dropHint': 'A fájl minden nyomvonalából vagy útvonalából az utazás egy túrája lesz.', + 'tours.import.dropNoPermission': 'Ehhez az utazáshoz nem adhatsz hozzá túrákat.', + 'tours.import.importing': 'GPX importálása…', + 'tours.days.label': 'Utazási napok', + 'tours.days.none': 'Még nincs az utazás egyik napjához sem rendelve.', + 'tours.days.short': 'Napok: {days}', + 'tours.days.short.one': 'Nap: {days}', + 'tours.alreadyOnDay': 'Ez a túra már szerepel ezen a napon.', + 'tours.conversion.title': 'Szerkeszthető változat', + 'tours.conversion.intro': + 'Az import egy rögzített nyomvonal. Tervezz belőle szerkeszthető változatot: a TREK ellenőrzőpontokat vesz ki belőle, újratervezi rajtuk az útvonalat, és megmutatja, mennyire közelíti meg. Semmi sem változik, amíg nem döntesz.', + 'tours.conversion.replace': 'Import lecserélése', + 'tours.conversion.replaceHint': + 'Helyben újratervezi ezt a túrát: a napjai és jegyzetei megmaradnak, a rögzített nyomvonalat pedig megőrizzük, hogy visszatérhess hozzá.', + 'tours.conversion.adoptHint': 'Új túrát tervez az import mellé, amely változatlan marad.', + 'tours.conversion.replacing': + 'Megnyitva a szerkesztőben. Mentsd el az import lecseréléséhez; a rögzített nyomvonal visszaállítható marad.', + 'tours.recorded.title': 'Rögzített nyomvonal', + 'tours.recorded.hint': 'Ezt a túrát egy import alapján tervezték újra. A nyomvonal, amellyel rögzítették, megmaradt.', + 'tours.recorded.restore': 'Rögzített nyomvonal visszaállítása', + 'tours.recorded.confirmTitle': 'Visszaállítod a rögzített nyomvonalat?', + 'tours.recorded.confirmBody': + 'Az útvonalpontok és a tervezett vonal törlődnek, a túra pedig ismét azt a nyomvonalat mutatja, amellyel rögzítették. A napjai és jegyzetei megmaradnak.', + 'tours.recorded.restored': 'Rögzített nyomvonal visszaállítva', + 'tours.recorded.failed': 'A rögzített nyomvonalat nem sikerült visszaállítani.', + 'tours.conversion.analyze': 'Szerkeszthető változat tervezése', + 'tours.conversion.analyzing': 'Útvonaltervezés {count} ellenőrzőponton át…', + 'tours.conversion.analyzing.one': 'Útvonaltervezés {count} ellenőrzőponton át…', + 'tours.conversion.failedTrack': 'Ez a nyomvonal túl rövid ahhoz, hogy túrát lehessen tervezni belőle.', + 'tours.conversion.failedRouting': 'Nem található útvonal az ellenőrzőpontokon át.', + 'tours.conversion.controlPoints': 'Ellenőrzőpontok', + 'tours.conversion.withinCorridor': 'A nyomvonaltól legfeljebb {m} m-re', + 'tours.conversion.importLength': 'Import', // same-as-en + 'tours.conversion.suggestionLength': 'Javaslat', + 'tours.conversion.maxDeviation': 'Legnagyobb eltérés', + 'tours.conversion.legendImport': 'Import', // same-as-en + 'tours.conversion.legendSuggestion': 'Javaslat', + 'tours.conversion.close': + 'A javaslat szorosan követi a nyomvonalat. Újratervezett útvonal, ezért nem pontosan ugyanaz a vonal.', + 'tours.conversion.different': + 'A javaslat hosszabb szakaszokon eltér a nyomvonaltól. Ellenőrizd a térképen, mielőtt tervezel vele.', + 'tours.conversion.adopt': 'Tervezés új túraként', + 'tours.conversion.discard': 'Elvetés', + 'tours.conversion.adopted': 'Megnyitva új túraként. Mentsd el, hogy megmaradjon; az import változatlan marad.', + 'tours.derivedFrom': 'Tervezve ebből: {name}', + 'tours.derivedFromHint': 'Importból újratervezve, ezért eltérhet a rögzített nyomvonaltól.', 'tours.delete.confirmBody': 'Végleg törli ezt a túrát? Az útvonala, szerkeszthető útvonalpontjai és a napokhoz rendelései is törlődnek. A művelet nem vonható vissza.', 'tours.delete.bulkConfirmBody': @@ -38,9 +91,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Legnagyobb tengerszint feletti magasság', 'tours.detail.ascent': 'Szintemelkedés', 'tours.detail.descent': 'Szintcsökkenés', - 'tours.detail.difficulty': 'Nehézség', 'tours.detail.source': 'Forrás', - 'tours.detail.gpxReadOnly': 'A GPX-túrák csak megtekinthetők, a tervezőben még nem szerkeszthetők.', 'tours.detail.navigationHint': 'A navigáció a túra kiindulópontját nyitja meg; nem vezet végig az útvonalon.', 'tours.planner.title': 'Túra tervezése', 'tours.planner.newTour': 'Új túra tervezése', diff --git a/shared/src/i18n/hu/wanderer.ts b/shared/src/i18n/hu/wanderer.ts new file mode 100644 index 000000000..22bc437bb --- /dev/null +++ b/shared/src/i18n/hu/wanderer.ts @@ -0,0 +1,95 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Kapcsolódva', + 'wanderer.notConnected': 'Nincs kapcsolódva', + 'wanderer.intro': + 'Csatlakoztasd saját wanderer-példányodat, hogy túraként importálhasd a túraútvonalait. A token a szerveren marad, és többé nem jelenik meg.', + 'wanderer.url': 'A példány URL-je', + 'wanderer.token': 'API-token', + 'wanderer.tokenHint': 'A wanderer alkalmazásban a Beállítások, Fiók, API-tokenek menüpont alatt.', + 'wanderer.tokenInvalid': 'Ez nem wanderer API-token. A token wanderer_key_ előtaggal kezdődik.', + 'wanderer.allowInsecureTls': 'Önaláírt tanúsítvány engedélyezése', + 'wanderer.allowInsecureTlsHint': 'Csak a saját hálózatodon lévő példányhoz.', + 'wanderer.test.button': 'Kapcsolat tesztelése', + 'wanderer.test.ok': 'Kapcsolódva.', + 'wanderer.test.okCount': 'Kapcsolódva. {count} túraútvonaladat importálhatod.', + 'wanderer.test.okCount.one': 'Kapcsolódva. {count} túraútvonaladat importálhatod.', + 'wanderer.disconnect': 'Leválasztás', + 'wanderer.saved': 'wanderer-kapcsolat mentve', + 'wanderer.disconnected': 'wanderer-kapcsolat eltávolítva', + 'wanderer.loadFailed': 'Nem sikerült betölteni a wanderer-kapcsolatot', + 'wanderer.privateIp': + 'Ez a cím egy magánhálózatban van. Ha a TREK nem éri el, a rendszergazdának engedélyeznie kell a belső hálózatokat.', + 'wanderer.error.disabled': 'A rendszergazda nem engedélyezte a wanderer használatát a túrákhoz.', + 'wanderer.error.not_connected': 'A wanderer még nincs csatlakoztatva.', + 'wanderer.error.invalid_url': 'Ez a cím nem érvényes wanderer-URL.', + 'wanderer.error.blocked_address': + 'A TREK nem érheti el ezt a címet. A saját hálózatodon lévő példányhoz a rendszergazdának be kell állítania: ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': 'A wanderer nem érhető el. Ellenőrizd a címet, és hogy fut-e a példány.', + 'wanderer.error.tls': + 'A példány tanúsítványát nem sikerült ellenőrizni. Önaláírt tanúsítvány esetén engedélyezd lent.', + 'wanderer.error.unauthorized': 'A wanderer nem fogadta el az API-tokent. Lehet, hogy visszavonták vagy lejárt.', + 'wanderer.error.forbidden': 'A wanderer megtagadta a hozzáférést ehhez a túraútvonalhoz.', + 'wanderer.error.not_found': 'Ez a túraútvonal már nem létezik a wanderer alkalmazásban.', + 'wanderer.error.not_own_trail': 'Csak a saját wanderer-túraútvonalaid importálhatók.', + 'wanderer.error.invalid_response': + 'A válasz nem a wanderer alkalmazástól érkezett. Ellenőrizd, hogy a cím magára a wanderer alkalmazásra mutat-e.', + 'wanderer.error.too_large': 'A túraútvonal fájlja nagyobb 10 MB-nál.', + 'wanderer.error.server_error': 'A wanderer hibával válaszolt. Próbáld újra később.', + 'wanderer.error.offline': 'Offline vagy. A wanderer csak online kereshető.', + 'wanderer.error.preview_stale': 'A túraútvonal megváltozott a wanderer alkalmazásban, miközben nézted.', + 'wanderer.error.already_imported': 'Ez a túraútvonal már szerepel az utazásban.', + 'wanderer.error.no_track': 'Ennek a túraútvonalnak nincs importálható nyomvonala vagy útvonala.', + 'wanderer.error.unknown': 'Hiba történt a wanderer használata közben.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Túraútvonalaid', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Túraútvonal importálása a wanderer-példányodból', + 'wanderer.browser.backToList': 'Vissza a találatokhoz', + 'wanderer.browser.searchPlaceholder': 'Túraútvonalak keresése', + 'wanderer.browser.areaOff': 'Keresés ezen a térképterületen', + 'wanderer.browser.areaOn': 'Ezen a térképterületen keresel', + 'wanderer.browser.areaOnHint': 'Csak a térkép látható részébe benyúló túraútvonalak', + 'wanderer.browser.areaOffHint': 'Keresés ismét mindenhol', + 'wanderer.browser.connectTitle': 'wanderer csatlakoztatása', + 'wanderer.browser.connect': 'Csatlakoztatás a beállításokban', + 'wanderer.browser.empty': 'Nem található túraútvonal', + 'wanderer.browser.emptyArea': 'Ezen a térképterületen nincs túraútvonal', + 'wanderer.browser.emptyHint': 'Próbálj másik szót, vagy mozgasd a térképet.', + 'wanderer.browser.count': '{count} túraútvonal', + 'wanderer.browser.count.one': '{count} túraútvonal', + 'wanderer.browser.more': 'Továbbiak betöltése', + 'wanderer.browser.public': 'Nyilvános', + 'wanderer.browser.private': 'Privát', + 'wanderer.browser.publicHint': 'Nyilvános a wanderer alkalmazásban: ott a példányod minden felhasználója láthatja', + 'wanderer.browser.privateHint': 'Privát a wanderer alkalmazásban: ott csak te láthatod', + 'wanderer.browser.inTrip': 'Ebben az utazásban', + 'wanderer.browser.inTripHint': 'Már importálva ebbe az utazásba', + 'wanderer.browser.areaBadge': 'Térképterület', + 'wanderer.preview.loading': 'Túraútvonal beolvasása a wanderer alkalmazásból…', + 'wanderer.preview.measured': 'A GPX-fájlból', + 'wanderer.preview.points': 'Nyomvonalpontok', + 'wanderer.preview.noElevation': 'A fájl nem tartalmaz magassági adatokat, ezért az emelkedés és a lejtés ismeretlen.', + 'wanderer.preview.openExternal': 'Megnyitás a wanderer alkalmazásban', + 'wanderer.preview.import': 'Importálás az utazásba', + 'wanderer.preview.importNewVersion': 'Importálás új változatként', + 'wanderer.preview.openSnapshot': 'Túra megnyitása', + 'wanderer.preview.openSnapshotShort': 'A túra megnyitása ebben az utazásban', + 'wanderer.preview.newHint': + 'Az importálás önálló túraként másolja a túraútvonalat ebbe az utazásba. A wanderer alkalmazásban később végzett módosítások nem érintik.', + 'wanderer.preview.unchanged': + 'Már szerepel ebben az utazásban „{name}” néven, importálva: {date}, és azóta nem változott a wanderer alkalmazásban.', + 'wanderer.preview.changed': 'Importálva: {date}, „{name}” néven. Azóta változott a wanderer alkalmazásban: {parts}.', + 'wanderer.preview.editedLocally': 'Az utazásban lévő túrát azóta itt szerkesztették.', + 'wanderer.preview.newVersionHint': + 'Az újbóli importálás egy új változatot ad mellé. Az utazásban lévő túra változatlan marad.', + 'wanderer.preview.change.name': 'név', + 'wanderer.preview.change.description': 'leírás', + 'wanderer.preview.change.route': 'útvonal', + 'wanderer.tourTitle': 'wanderer-túra', + 'wanderer.import.done': 'Importálva: „{name}”', + 'wanderer.import.stale': 'A túraútvonal megváltozott a wanderer alkalmazásban. Íme az aktuális változat.', +}; +export default wanderer; diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index f72bd1fd5..793804827 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -705,6 +705,11 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Addon diperbarui', 'admin.addons.toast.error': 'Gagal memperbarui addon', 'admin.addons.group.count': '{enabled} dari {total} aktif', + 'admin.addons.subOptions': '{count} opsi', + 'admin.addons.subOptionsOn': '{count} aktif', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Pengguna dapat menghubungkan instans wanderer milik sendiri dan mengimpor jalurnya sebagai tur', 'admin.addons.noAddons': 'Tidak ada addon yang tersedia', 'admin.weather.title': 'Data Cuaca', 'admin.weather.badge': 'Sejak 24 Maret 2026', diff --git a/shared/src/i18n/id/index.ts b/shared/src/i18n/id/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/id/index.ts +++ b/shared/src/i18n/id/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts index ee470e0d1..3ae28705e 100644 --- a/shared/src/i18n/id/tours.ts +++ b/shared/src/i18n/id/tours.ts @@ -21,8 +21,59 @@ const tours: TranslationStrings = { 'tours.caution.tooltip': 'Data elevasi atau metrik belum lengkap; diimpor dengan peringatan', 'tours.import.caution': 'Diimpor dengan peringatan; data elevasi atau metrik belum lengkap', 'tours.import.success': '{count} tur diimpor dari GPX', - 'tours.import.noTrack': 'Tidak ditemukan jejak atau rute dalam berkas GPX tersebut', - 'tours.import.error': 'Berkas GPX tidak dapat diimpor', + 'tours.import.noTrackIn': 'Tidak ditemukan jejak atau rute dalam {name}', + 'tours.import.errorIn': '{name} tidak dapat diimpor', + 'tours.import.button': 'Impor GPX', + 'tours.import.hint': 'Impor berkas GPX sebagai tur perjalanan ini', + 'tours.import.unsupported': 'Hanya berkas GPX yang dapat diimpor sebagai tur.', + 'tours.import.skipped': '{count} jejak sudah ada dalam perjalanan ini dan dilewati', + 'tours.import.allSkipped': 'Semua isi berkas itu sudah ada dalam perjalanan ini.', + 'tours.import.drop': 'Lepaskan berkas GPX untuk mengimpornya', + 'tours.import.dropHint': 'Setiap jejak atau rute dalam berkas menjadi tur perjalanan ini.', + 'tours.import.dropNoPermission': 'Anda tidak dapat menambahkan tur ke perjalanan ini.', + 'tours.import.importing': 'Mengimpor GPX…', + 'tours.days.label': 'Hari perjalanan', + 'tours.days.none': 'Belum ada di hari mana pun dalam perjalanan.', + 'tours.days.short': 'Hari {days}', + 'tours.alreadyOnDay': 'Tur ini sudah ada di hari tersebut.', + 'tours.conversion.title': 'Versi yang dapat diedit', + 'tours.conversion.intro': + 'Impor adalah jejak yang direkam. Rencanakan versi yang dapat diedit: TREK mengambil titik kontrol darinya, merutekannya ulang, dan menunjukkan seberapa dekat hasilnya. Tidak ada yang berubah sampai Anda memilih.', + 'tours.conversion.replace': 'Ganti impor', + 'tours.conversion.replaceHint': + 'Merencanakan ulang tur ini di tempat: hari dan catatannya tetap ada, dan jejak yang direkam disimpan agar Anda dapat kembali ke sana.', + 'tours.conversion.adoptHint': 'Merencanakan tur baru di samping impor, yang tetap seperti semula.', + 'tours.conversion.replacing': + 'Dibuka di editor. Simpan untuk mengganti impor; jejak yang direkam tetap dapat dipulihkan.', + 'tours.recorded.title': 'Jejak yang direkam', + 'tours.recorded.hint': + 'Tur ini direncanakan ulang di atas sebuah impor. Jejak yang digunakan saat merekamnya tetap disimpan.', + 'tours.recorded.restore': 'Pulihkan jejak yang direkam', + 'tours.recorded.confirmTitle': 'Pulihkan jejak yang direkam?', + 'tours.recorded.confirmBody': + 'Titik lintasan dan garis rute akan dihapus, dan tur kembali menampilkan jejak yang digunakan saat merekamnya. Hari dan catatannya tetap ada.', + 'tours.recorded.restored': 'Jejak yang direkam dipulihkan', + 'tours.recorded.failed': 'Jejak yang direkam tidak dapat dipulihkan.', + 'tours.conversion.analyze': 'Rencanakan versi yang dapat diedit', + 'tours.conversion.analyzing': 'Merutekan melalui {count} titik kontrol…', + 'tours.conversion.failedTrack': 'Jejak ini terlalu pendek untuk direncanakan menjadi tur.', + 'tours.conversion.failedRouting': 'Tidak ditemukan rute melalui titik kontrol.', + 'tours.conversion.controlPoints': 'Titik kontrol', + 'tours.conversion.withinCorridor': 'Dalam jarak {m} m dari jejak', + 'tours.conversion.importLength': 'Impor', + 'tours.conversion.suggestionLength': 'Saran', + 'tours.conversion.maxDeviation': 'Penyimpangan terbesar', + 'tours.conversion.legendImport': 'Impor', + 'tours.conversion.legendSuggestion': 'Saran', + 'tours.conversion.close': + 'Saran ini mengikuti jejak dengan dekat. Karena dirutekan ulang, garisnya tidak sama persis.', + 'tours.conversion.different': + 'Saran ini menyimpang dari jejak pada bagian yang cukup panjang. Periksa di peta sebelum Anda merencanakan dengannya.', + 'tours.conversion.adopt': 'Rencanakan sebagai tur baru', + 'tours.conversion.discard': 'Buang', + 'tours.conversion.adopted': 'Dibuka sebagai tur baru. Simpan untuk mempertahankannya; impor tetap seperti semula.', + 'tours.derivedFrom': 'Direncanakan dari {name}', + 'tours.derivedFromHint': 'Dirutekan ulang dari impor, jadi bisa berbeda dari jejak yang direkam.', 'tours.delete.confirmBody': 'Hapus tur ini secara permanen? Rute, titik arah yang dapat diedit, dan penetapan harinya akan dihapus. Tindakan ini tidak dapat dibatalkan.', 'tours.delete.bulkConfirmBody': @@ -37,9 +88,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Ketinggian maksimum', 'tours.detail.ascent': 'Total kenaikan elevasi', 'tours.detail.descent': 'Total penurunan elevasi', - 'tours.detail.difficulty': 'Tingkat kesulitan', 'tours.detail.source': 'Sumber', - 'tours.detail.gpxReadOnly': 'Tur GPX hanya dapat dilihat dan belum dapat diedit di perencana.', 'tours.detail.navigationHint': 'Navigasi membuka titik awal jalur, bukan memberikan panduan sepanjang rute.', 'tours.planner.title': 'Rencanakan tur', 'tours.planner.newTour': 'Rencanakan tur baru', diff --git a/shared/src/i18n/id/wanderer.ts b/shared/src/i18n/id/wanderer.ts new file mode 100644 index 000000000..4e1f9b110 --- /dev/null +++ b/shared/src/i18n/id/wanderer.ts @@ -0,0 +1,93 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Terhubung', + 'wanderer.notConnected': 'Tidak terhubung', + 'wanderer.intro': + 'Hubungkan instans wanderer milik Anda untuk mengimpor jalurnya sebagai tur. Token tetap di server dan tidak akan ditampilkan lagi.', + 'wanderer.url': 'URL instans', + 'wanderer.token': 'Token API', + 'wanderer.tokenHint': 'Di wanderer, buka Pengaturan, Akun, Token API.', + 'wanderer.tokenInvalid': 'Itu bukan token API wanderer. Token diawali dengan wanderer_key_.', + 'wanderer.allowInsecureTls': 'Izinkan sertifikat yang ditandatangani sendiri', + 'wanderer.allowInsecureTlsHint': 'Hanya untuk instans di jaringan Anda sendiri.', + 'wanderer.test.button': 'Uji koneksi', + 'wanderer.test.ok': 'Terhubung.', + 'wanderer.test.okCount': 'Terhubung. Anda dapat mengimpor {count} jalur milik Anda.', + 'wanderer.disconnect': 'Putuskan', + 'wanderer.saved': 'Koneksi wanderer disimpan', + 'wanderer.disconnected': 'Koneksi wanderer dihapus', + 'wanderer.loadFailed': 'Koneksi wanderer tidak dapat dimuat', + 'wanderer.privateIp': + 'Alamat ini berada di jaringan privat. Jika TREK tidak dapat menjangkaunya, administrator harus mengizinkan jaringan internal.', + 'wanderer.error.disabled': 'Admin Anda belum mengaktifkan wanderer untuk Tur.', + 'wanderer.error.not_connected': 'wanderer belum terhubung.', + 'wanderer.error.invalid_url': 'Alamat itu bukan URL wanderer yang valid.', + 'wanderer.error.blocked_address': + 'TREK tidak boleh menjangkau alamat itu. Untuk instans di jaringan Anda sendiri, administrator harus mengatur ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': + 'wanderer tidak dapat dijangkau. Periksa alamatnya dan pastikan instans sedang berjalan.', + 'wanderer.error.tls': 'Sertifikat instans tidak dapat diverifikasi. Jika ditandatangani sendiri, izinkan di bawah.', + 'wanderer.error.unauthorized': 'wanderer tidak menerima token API. Token mungkin telah dicabut atau kedaluwarsa.', + 'wanderer.error.forbidden': 'wanderer menolak akses ke jalur ini.', + 'wanderer.error.not_found': 'Jalur ini sudah tidak ada di wanderer.', + 'wanderer.error.not_own_trail': 'Hanya jalur wanderer milik Anda sendiri yang dapat diimpor.', + 'wanderer.error.invalid_response': + 'Jawaban tidak berasal dari wanderer. Pastikan alamat mengarah ke wanderer itu sendiri.', + 'wanderer.error.too_large': 'Berkas jalur lebih besar dari 10 MB.', + 'wanderer.error.server_error': 'wanderer menjawab dengan galat. Coba lagi nanti.', + 'wanderer.error.offline': 'Anda sedang offline. Pencarian di wanderer hanya bisa dilakukan saat online.', + 'wanderer.error.preview_stale': 'Jalur berubah di wanderer saat Anda melihatnya.', + 'wanderer.error.already_imported': 'Jalur ini sudah ada dalam perjalanan.', + 'wanderer.error.no_track': 'Jalur ini tidak memiliki jejak atau rute untuk diimpor.', + 'wanderer.error.unknown': 'Terjadi masalah dengan wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Jalur Anda', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Impor jalur dari instans wanderer Anda', + 'wanderer.browser.backToList': 'Kembali ke hasil', + 'wanderer.browser.searchPlaceholder': 'Cari jalur', + 'wanderer.browser.areaOff': 'Cari di area peta ini', + 'wanderer.browser.areaOn': 'Mencari di area peta ini', + 'wanderer.browser.areaOnHint': 'Hanya jalur yang menjangkau bagian peta yang terlihat', + 'wanderer.browser.areaOffHint': 'Cari di semua tempat lagi', + 'wanderer.browser.connectTitle': 'Hubungkan wanderer', + 'wanderer.browser.connect': 'Hubungkan di pengaturan', + 'wanderer.browser.empty': 'Tidak ada jalur ditemukan', + 'wanderer.browser.emptyArea': 'Tidak ada jalur di area peta ini', + 'wanderer.browser.emptyHint': 'Coba kata lain atau geser peta.', + 'wanderer.browser.count': '{count} jalur', + 'wanderer.browser.more': 'Muat lebih banyak', + 'wanderer.browser.public': 'Publik', + 'wanderer.browser.private': 'Privat', + 'wanderer.browser.publicHint': 'Publik di wanderer: semua orang di instans Anda dapat melihatnya di sana', + 'wanderer.browser.privateHint': 'Privat di wanderer: hanya Anda yang dapat melihatnya di sana', + 'wanderer.browser.inTrip': 'Dalam perjalanan ini', + 'wanderer.browser.inTripHint': 'Sudah diimpor ke perjalanan ini', + 'wanderer.browser.areaBadge': 'Area peta', + 'wanderer.preview.loading': 'Membaca jalur dari wanderer…', + 'wanderer.preview.measured': 'Dari berkas GPX', + 'wanderer.preview.points': 'Titik jejak', + 'wanderer.preview.noElevation': 'Berkas tidak memiliki data elevasi, jadi kenaikan dan penurunan tidak diketahui.', + 'wanderer.preview.openExternal': 'Buka di wanderer', + 'wanderer.preview.import': 'Impor ke perjalanan', + 'wanderer.preview.importNewVersion': 'Impor sebagai versi baru', + 'wanderer.preview.openSnapshot': 'Buka tur', + 'wanderer.preview.openSnapshotShort': 'Buka tur dalam perjalanan ini', + 'wanderer.preview.newHint': + 'Mengimpor akan menyalin jalur ke perjalanan ini sebagai tur tersendiri. Perubahan selanjutnya di wanderer tidak memengaruhinya.', + 'wanderer.preview.unchanged': + 'Sudah ada dalam perjalanan ini sebagai “{name}”, diimpor pada {date}, dan tidak berubah di wanderer sejak itu.', + 'wanderer.preview.changed': 'Diimpor pada {date} sebagai “{name}”. Berubah di wanderer sejak itu: {parts}.', + 'wanderer.preview.editedLocally': 'Tur dalam perjalanan ini telah diedit di sini sejak itu.', + 'wanderer.preview.newVersionHint': + 'Mengimpor lagi akan menambahkan versi baru di sampingnya. Tur dalam perjalanan tetap seperti semula.', + 'wanderer.preview.change.name': 'nama', + 'wanderer.preview.change.description': 'deskripsi', + 'wanderer.preview.change.route': 'rute', + 'wanderer.tourTitle': 'Tur wanderer', + 'wanderer.import.done': '“{name}” diimpor', + 'wanderer.import.stale': 'Jalur berubah di wanderer. Berikut versi terbarunya.', +}; +export default wanderer; diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index e8b16dc53..d927d974b 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -659,6 +659,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Modulo aggiornato', 'admin.addons.toast.error': 'Impossibile aggiornare il modulo', 'admin.addons.group.count': '{enabled} di {total} attivi', + 'admin.addons.subOptions': '{count} opzioni', + 'admin.addons.subOptions.one': '{count} opzione', + 'admin.addons.subOptionsOn': '{count} attive', + 'admin.addons.subOptionsOn.one': '{count} attiva', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Gli utenti possono collegare la propria istanza wanderer e importarne i sentieri come escursioni', 'admin.addons.noAddons': 'Nessun modulo disponibile', 'admin.weather.title': 'Dati meteo', 'admin.weather.badge': 'Dal 24 marzo 2026', diff --git a/shared/src/i18n/it/index.ts b/shared/src/i18n/it/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/it/index.ts +++ b/shared/src/i18n/it/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts index 5b245e00d..b47958f69 100644 --- a/shared/src/i18n/it/tours.ts +++ b/shared/src/i18n/it/tours.ts @@ -22,8 +22,62 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Importata con riserva: quote o metriche incomplete', 'tours.import.success': '{count} escursioni importate da GPX', 'tours.import.success.one': '{count} escursione importata da GPX', - 'tours.import.noTrack': 'Nessuna traccia o percorso trovato nel file GPX', - 'tours.import.error': 'Impossibile importare il file GPX', + 'tours.import.noTrackIn': 'Nessuna traccia o percorso trovato in {name}', + 'tours.import.errorIn': 'Impossibile importare {name}', + 'tours.import.button': 'Importa GPX', + 'tours.import.hint': 'Importa file GPX come escursioni di questo viaggio', + 'tours.import.unsupported': 'Solo i file GPX possono essere importati come escursioni.', + 'tours.import.skipped': '{count} tracce erano già in questo viaggio e sono state saltate', + 'tours.import.skipped.one': '{count} traccia era già in questo viaggio ed è stata saltata', + 'tours.import.allSkipped': 'Tutto il contenuto di quel file è già in questo viaggio.', + 'tours.import.drop': 'Rilascia i file GPX per importarli', + 'tours.import.dropHint': "Ogni traccia o percorso di un file diventa un'escursione di questo viaggio.", + 'tours.import.dropNoPermission': 'Non puoi aggiungere escursioni a questo viaggio.', + 'tours.import.importing': 'Importazione GPX…', + 'tours.days.label': 'Giorni del viaggio', + 'tours.days.none': 'Non ancora in nessun giorno del viaggio.', + 'tours.days.short': 'Giorni {days}', + 'tours.days.short.one': 'Giorno {days}', + 'tours.alreadyOnDay': 'Questa escursione è già in quel giorno.', + 'tours.conversion.title': 'Versione modificabile', + 'tours.conversion.intro': + "Un'importazione è una traccia registrata. Pianifica una versione modificabile: TREK ne ricava dei punti di controllo, ricalcola il percorso attraverso di essi e mostra quanto il risultato si avvicina. Non cambia nulla finché non scegli.", + 'tours.conversion.replace': "Sostituisci l'importazione", + 'tours.conversion.replaceHint': + 'Ripianifica questa escursione sul posto: i suoi giorni e le note restano, e la traccia registrata viene conservata così puoi tornarci.', + 'tours.conversion.adoptHint': "Pianifica una nuova escursione accanto all'importazione, che resta com'è.", + 'tours.conversion.replacing': + "Aperta nell'editor. Salvala per sostituire l'importazione; la traccia registrata resta ripristinabile.", + 'tours.recorded.title': 'Traccia registrata', + 'tours.recorded.hint': + "Questa escursione è stata ripianificata a partire da un'importazione. La traccia con cui è stata registrata è conservata.", + 'tours.recorded.restore': 'Ripristina la traccia registrata', + 'tours.recorded.confirmTitle': 'Ripristinare la traccia registrata?', + 'tours.recorded.confirmBody': + "I punti di passaggio e la linea calcolata vengono eliminati, e l'escursione mostra di nuovo la traccia con cui è stata registrata. I suoi giorni e le note restano.", + 'tours.recorded.restored': 'Traccia registrata ripristinata', + 'tours.recorded.failed': 'Impossibile ripristinare la traccia registrata.', + 'tours.conversion.analyze': 'Pianifica una versione modificabile', + 'tours.conversion.analyzing': 'Calcolo del percorso attraverso {count} punti di controllo…', + 'tours.conversion.analyzing.one': 'Calcolo del percorso attraverso {count} punto di controllo…', + 'tours.conversion.failedTrack': "Questa traccia è troppo corta per pianificarne un'escursione.", + 'tours.conversion.failedRouting': 'Nessun percorso trovato attraverso i punti di controllo.', + 'tours.conversion.controlPoints': 'Punti di controllo', + 'tours.conversion.withinCorridor': 'Entro {m} m dalla traccia', + 'tours.conversion.importLength': 'Importata', + 'tours.conversion.suggestionLength': 'Proposta', + 'tours.conversion.maxDeviation': 'Scostamento massimo', + 'tours.conversion.legendImport': 'Importata', + 'tours.conversion.legendSuggestion': 'Proposta', + 'tours.conversion.close': + 'La proposta segue da vicino la traccia. Il percorso è ricalcolato, quindi non è la stessa linea.', + 'tours.conversion.different': + 'La proposta si allontana dalla traccia per tratti lunghi. Controllala sulla mappa prima di pianificare con essa.', + 'tours.conversion.adopt': 'Pianifica come nuova escursione', + 'tours.conversion.discard': 'Scarta', + 'tours.conversion.adopted': "Aperta come nuova escursione. Salvala per conservarla; l'importazione resta com'è.", + 'tours.derivedFrom': 'Pianificata da {name}', + 'tours.derivedFromHint': "Percorso ricalcolato da un'importazione, quindi può differire dalla traccia registrata.", 'tours.delete.confirmBody': 'Eliminare definitivamente questo tour? Verranno rimossi il percorso, i waypoint modificabili e le assegnazioni ai giorni. L’azione non può essere annullata.', 'tours.delete.bulkConfirmBody': @@ -38,10 +92,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Quota massima', 'tours.detail.ascent': 'Dislivello positivo', 'tours.detail.descent': 'Dislivello negativo', - 'tours.detail.difficulty': 'Difficoltà', 'tours.detail.source': 'Origine', - 'tours.detail.gpxReadOnly': - 'Le escursioni GPX sono di sola lettura e non possono ancora essere modificate nel pianificatore.', 'tours.detail.navigationHint': 'La navigazione apre il punto di partenza del sentiero; non guida lungo il percorso.', 'tours.planner.title': "Pianifica un'escursione", 'tours.planner.newTour': 'Pianifica nuova escursione', diff --git a/shared/src/i18n/it/wanderer.ts b/shared/src/i18n/it/wanderer.ts new file mode 100644 index 000000000..9e9138574 --- /dev/null +++ b/shared/src/i18n/it/wanderer.ts @@ -0,0 +1,96 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Collegato', + 'wanderer.notConnected': 'Non collegato', + 'wanderer.intro': + 'Collega la tua istanza wanderer per importarne i sentieri come escursioni. Il token resta sul server e non viene più mostrato.', + 'wanderer.url': "Indirizzo dell'istanza", + 'wanderer.token': 'Token API', + 'wanderer.tokenHint': 'In wanderer sotto Impostazioni, Account, Token API.', + 'wanderer.tokenInvalid': 'Questo non è un token API di wanderer. Un token inizia con wanderer_key_.', + 'wanderer.allowInsecureTls': 'Consenti un certificato autofirmato', + 'wanderer.allowInsecureTlsHint': "Solo per un'istanza nella tua rete.", + 'wanderer.test.button': 'Prova connessione', + 'wanderer.test.ok': 'Collegato.', + 'wanderer.test.okCount': 'Collegato. Puoi importare {count} dei tuoi sentieri.', + 'wanderer.test.okCount.one': 'Collegato. Puoi importare {count} dei tuoi sentieri.', + 'wanderer.disconnect': 'Scollega', + 'wanderer.saved': 'Collegamento a wanderer salvato', + 'wanderer.disconnected': 'Collegamento a wanderer rimosso', + 'wanderer.loadFailed': 'Impossibile caricare il collegamento a wanderer', + 'wanderer.privateIp': + "Questo indirizzo è in una rete privata. Se TREK non riesce a raggiungerlo, l'amministratore deve consentire le reti interne.", + 'wanderer.error.disabled': 'Il tuo amministratore non ha abilitato wanderer per le escursioni.', + 'wanderer.error.not_connected': 'wanderer non è ancora collegato.', + 'wanderer.error.invalid_url': 'Questo indirizzo non è un URL di wanderer valido.', + 'wanderer.error.blocked_address': + "TREK non può raggiungere questo indirizzo. Per un'istanza nella tua rete l'amministratore deve impostare ALLOW_INTERNAL_NETWORK=true.", + 'wanderer.error.unreachable': + "Impossibile raggiungere wanderer. Controlla l'indirizzo e che l'istanza sia in esecuzione.", + 'wanderer.error.tls': "Impossibile verificare il certificato dell'istanza. Se è autofirmato, consentilo qui sotto.", + 'wanderer.error.unauthorized': + 'wanderer non ha accettato il token API. Potrebbe essere stato revocato o essere scaduto.', + 'wanderer.error.forbidden': "wanderer ha negato l'accesso a questo sentiero.", + 'wanderer.error.not_found': 'Questo sentiero non esiste più in wanderer.', + 'wanderer.error.not_own_trail': 'Si possono importare solo i tuoi sentieri di wanderer.', + 'wanderer.error.invalid_response': + "La risposta non proviene da wanderer. Controlla che l'indirizzo punti proprio a wanderer.", + 'wanderer.error.too_large': 'Il file del sentiero supera i 10 MB.', + 'wanderer.error.server_error': 'wanderer ha risposto con un errore. Riprova più tardi.', + 'wanderer.error.offline': 'Sei offline. In wanderer si può cercare solo online.', + 'wanderer.error.preview_stale': 'Il sentiero è cambiato in wanderer mentre lo stavi guardando.', + 'wanderer.error.already_imported': 'Questo sentiero è già nel viaggio.', + 'wanderer.error.no_track': 'Questo sentiero non ha tracce o percorsi da importare.', + 'wanderer.error.unknown': 'Qualcosa è andato storto con wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'I tuoi sentieri', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Importa un sentiero dalla tua istanza wanderer', + 'wanderer.browser.backToList': 'Torna ai risultati', + 'wanderer.browser.searchPlaceholder': 'Cerca sentieri', + 'wanderer.browser.areaOff': "Cerca in quest'area della mappa", + 'wanderer.browser.areaOn': "Ricerca in quest'area della mappa", + 'wanderer.browser.areaOnHint': 'Solo sentieri che entrano nella parte di mappa visibile', + 'wanderer.browser.areaOffHint': 'Cerca di nuovo ovunque', + 'wanderer.browser.connectTitle': 'Collega wanderer', + 'wanderer.browser.connect': 'Collega nelle impostazioni', + 'wanderer.browser.empty': 'Nessun sentiero trovato', + 'wanderer.browser.emptyArea': "Nessun sentiero in quest'area della mappa", + 'wanderer.browser.emptyHint': "Prova un'altra parola o sposta la mappa.", + 'wanderer.browser.count': '{count} sentieri', + 'wanderer.browser.count.one': '{count} sentiero', + 'wanderer.browser.more': 'Carica altri', + 'wanderer.browser.public': 'Pubblico', + 'wanderer.browser.private': 'Privato', + 'wanderer.browser.publicHint': 'Pubblico in wanderer: tutti sulla tua istanza possono vederlo lì', + 'wanderer.browser.privateHint': 'Privato in wanderer: solo tu puoi vederlo lì', + 'wanderer.browser.inTrip': 'In questo viaggio', + 'wanderer.browser.inTripHint': 'Già importato in questo viaggio', + 'wanderer.browser.areaBadge': 'Area della mappa', + 'wanderer.preview.loading': 'Lettura del sentiero da wanderer…', + 'wanderer.preview.measured': 'Dal file GPX', + 'wanderer.preview.points': 'Punti traccia', + 'wanderer.preview.noElevation': 'Il file non contiene quote, quindi salita e discesa sono sconosciute.', + 'wanderer.preview.openExternal': 'Apri in wanderer', + 'wanderer.preview.import': 'Importa nel viaggio', + 'wanderer.preview.importNewVersion': 'Importa come nuova versione', + 'wanderer.preview.openSnapshot': "Apri l'escursione", + 'wanderer.preview.openSnapshotShort': "Apri l'escursione in questo viaggio", + 'wanderer.preview.newHint': + "L'importazione copia il sentiero in questo viaggio come escursione a sé. Le modifiche successive in wanderer non la cambiano.", + 'wanderer.preview.unchanged': + 'Già in questo viaggio come “{name}”, importato il {date} e da allora invariato in wanderer.', + 'wanderer.preview.changed': 'Importato il {date} come “{name}”. Da allora modificato in wanderer: {parts}.', + 'wanderer.preview.editedLocally': "Da allora l'escursione di questo viaggio è stata modificata qui.", + 'wanderer.preview.newVersionHint': + "Importando di nuovo si aggiunge una nuova versione accanto. L'escursione nel viaggio resta com'è.", + 'wanderer.preview.change.name': 'nome', + 'wanderer.preview.change.description': 'descrizione', + 'wanderer.preview.change.route': 'percorso', + 'wanderer.tourTitle': 'Escursione wanderer', + 'wanderer.import.done': '“{name}” importato', + 'wanderer.import.stale': 'Il sentiero è cambiato in wanderer. Ecco la versione attuale.', +}; +export default wanderer; diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 4a4677c78..705c4323c 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -668,6 +668,11 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'アドオンを更新しました', 'admin.addons.toast.error': 'アドオンの更新に失敗しました', 'admin.addons.group.count': '{total} 件中 {enabled} 件が有効', + 'admin.addons.subOptions': '{count}件のオプション', + 'admin.addons.subOptionsOn': '{count}件が有効', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'ユーザーが自分のwandererインスタンスを接続し、そのトレイルをツアーとしてインポートできます', 'admin.addons.noAddons': '利用可能なアドオンはありません', 'admin.weather.title': '天気データ', 'admin.weather.badge': '2026年3月24日以降', diff --git a/shared/src/i18n/ja/index.ts b/shared/src/i18n/ja/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/ja/index.ts +++ b/shared/src/i18n/ja/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts index 954c68f57..16a32c0e6 100644 --- a/shared/src/i18n/ja/tours.ts +++ b/shared/src/i18n/ja/tours.ts @@ -21,8 +21,59 @@ const tours: TranslationStrings = { 'tours.caution.tooltip': '標高・各種データが不完全です。注意が必要な状態でインポートされました。', 'tours.import.caution': '標高・各種データが不完全なため、注意が必要な状態でインポートしました。', 'tours.import.success': 'GPXから{count}件のツアーをインポートしました', - 'tours.import.noTrack': 'このGPXファイルにトラックやルートが見つかりません', - 'tours.import.error': 'GPXファイルをインポートできませんでした', + 'tours.import.noTrackIn': '{name}にトラックやルートが見つかりません', + 'tours.import.errorIn': '{name}をインポートできませんでした', + 'tours.import.button': 'GPXをインポート', + 'tours.import.hint': 'GPXファイルをこの旅行のツアーとしてインポートします', + 'tours.import.unsupported': 'ツアーとしてインポートできるのはGPXファイルのみです。', + 'tours.import.skipped': '{count}件のトラックはすでにこの旅行にあるため、スキップしました', + 'tours.import.allSkipped': 'このファイルの内容はすべて、すでにこの旅行にあります。', + 'tours.import.drop': 'GPXファイルをドロップしてインポート', + 'tours.import.dropHint': 'ファイル内の各トラックやルートが、それぞれこの旅行のツアーになります。', + 'tours.import.dropNoPermission': 'この旅行にツアーを追加する権限がありません。', + 'tours.import.importing': 'GPXをインポート中…', + 'tours.days.label': '旅行の日程', + 'tours.days.none': 'まだ旅行のどの日にも入っていません。', + 'tours.days.short': '{days}日目', + 'tours.alreadyOnDay': 'このツアーはすでにその日に入っています。', + 'tours.conversion.title': '編集可能なバージョン', + 'tours.conversion.intro': + 'インポートしたものは記録されたトラックです。編集可能なバージョンを計画すると、TREKがそこからコントロールポイントを取り出し、ルートを引き直して、どこまで近づけるかを表示します。選ぶまでは何も変わりません。', + 'tours.conversion.replace': 'インポートを置き換え', + 'tours.conversion.replaceHint': + 'このツアーをその場で計画し直します。日程とメモはそのまま残り、記録されたトラックも保持されるので、あとで戻すことができます。', + 'tours.conversion.adoptHint': + 'インポートしたものの横に新しいツアーを計画します。インポートしたものはそのまま残ります。', + 'tours.conversion.replacing': + 'エディターで開きました。保存するとインポートが置き換えられます。記録されたトラックは引き続き復元できます。', + 'tours.recorded.title': '記録されたトラック', + 'tours.recorded.hint': 'このツアーはインポートをもとに計画し直されています。記録時のトラックは保持されています。', + 'tours.recorded.restore': '記録されたトラックを復元', + 'tours.recorded.confirmTitle': '記録されたトラックを復元しますか?', + 'tours.recorded.confirmBody': + 'ルート上の地点と引き直した線は破棄され、ツアーには記録時のトラックが再び表示されます。日程とメモはそのまま残ります。', + 'tours.recorded.restored': '記録されたトラックを復元しました', + 'tours.recorded.failed': '記録されたトラックを復元できませんでした。', + 'tours.conversion.analyze': '編集可能なバージョンを計画', + 'tours.conversion.analyzing': '{count}個のコントロールポイントを通るルートを計算中…', + 'tours.conversion.failedTrack': 'このトラックは短すぎるため、ツアーを計画できません。', + 'tours.conversion.failedRouting': 'コントロールポイントを通るルートが見つかりませんでした。', + 'tours.conversion.controlPoints': 'コントロールポイント', + 'tours.conversion.withinCorridor': 'トラックから{m} m以内', + 'tours.conversion.importLength': 'インポート', + 'tours.conversion.suggestionLength': '提案', + 'tours.conversion.maxDeviation': '最大のずれ', + 'tours.conversion.legendImport': 'インポート', + 'tours.conversion.legendSuggestion': '提案', + 'tours.conversion.close': + '提案はトラックにほぼ沿っています。ルートを引き直しているため、まったく同じ線ではありません。', + 'tours.conversion.different': '提案は長い区間でトラックから外れています。計画に使う前に地図で確認してください。', + 'tours.conversion.adopt': '新しいツアーとして計画', + 'tours.conversion.discard': '破棄', + 'tours.conversion.adopted': + '新しいツアーとして開きました。残すには保存してください。インポートしたものはそのまま残ります。', + 'tours.derivedFrom': '{name}から計画', + 'tours.derivedFromHint': 'インポートから改めてルートを引いたため、記録されたトラックと異なる場合があります。', 'tours.delete.confirmBody': 'このツアーを完全に削除しますか?ルート、編集可能な経由地点、日程への割り当ても削除されます。この操作は取り消せません。', 'tours.delete.bulkConfirmBody': @@ -36,9 +87,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': '最高標高', 'tours.detail.ascent': '累積上昇量', 'tours.detail.descent': '累積下降量', - 'tours.detail.difficulty': '難易度', 'tours.detail.source': '出典', - 'tours.detail.gpxReadOnly': 'GPXツアーは閲覧専用です。現時点ではプランナーで編集できません。', 'tours.detail.navigationHint': 'ナビには登山口が表示されます。ルートに沿った案内は行いません。', 'tours.planner.title': 'ツアーを計画', 'tours.planner.newTour': '新しいツアーを計画', diff --git a/shared/src/i18n/ja/wanderer.ts b/shared/src/i18n/ja/wanderer.ts new file mode 100644 index 000000000..56bc78fab --- /dev/null +++ b/shared/src/i18n/ja/wanderer.ts @@ -0,0 +1,94 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': '接続済み', + 'wanderer.notConnected': '未接続', + 'wanderer.intro': + '自分のwandererインスタンスを接続すると、そのトレイルをツアーとしてインポートできます。トークンはサーバーに保存され、再び表示されることはありません。', + 'wanderer.url': 'インスタンスのURL', + 'wanderer.token': 'APIトークン', + 'wanderer.tokenHint': 'wandererの「設定」、「アカウント」、「APIトークン」にあります。', + 'wanderer.tokenInvalid': 'wandererのAPIトークンではありません。トークンは wanderer_key_ で始まります。', + 'wanderer.allowInsecureTls': '自己署名証明書を許可する', + 'wanderer.allowInsecureTlsHint': '自分のネットワーク内にあるインスタンスの場合のみ。', + 'wanderer.test.button': '接続をテスト', + 'wanderer.test.ok': '接続しました。', + 'wanderer.test.okCount': '接続しました。{count}件のトレイルをインポートできます。', + 'wanderer.disconnect': '接続解除', + 'wanderer.saved': 'wandererの接続を保存しました', + 'wanderer.disconnected': 'wandererの接続を削除しました', + 'wanderer.loadFailed': 'wandererの接続を読み込めませんでした', + 'wanderer.privateIp': + 'このアドレスはプライベートネットワーク内にあります。TREKから到達できない場合は、管理者が内部ネットワークを許可する必要があります。', + 'wanderer.error.disabled': '管理者がツアーでwandererを有効にしていません。', + 'wanderer.error.not_connected': 'wandererはまだ接続されていません。', + 'wanderer.error.invalid_url': 'このアドレスは有効なwandererのURLではありません。', + 'wanderer.error.blocked_address': + 'TREKはこのアドレスにアクセスできません。自分のネットワーク内のインスタンスには、管理者がALLOW_INTERNAL_NETWORK=trueを設定する必要があります。', + 'wanderer.error.unreachable': + 'wandererに接続できませんでした。アドレスと、インスタンスが動作しているかを確認してください。', + 'wanderer.error.tls': 'インスタンスの証明書を検証できませんでした。自己署名証明書の場合は、下で許可してください。', + 'wanderer.error.unauthorized': + 'wandererがAPIトークンを受け付けませんでした。取り消されたか、有効期限が切れている可能性があります。', + 'wanderer.error.forbidden': 'wandererがこのトレイルへのアクセスを拒否しました。', + 'wanderer.error.not_found': 'このトレイルはwandererにもうありません。', + 'wanderer.error.not_own_trail': 'インポートできるのは自分のwandererトレイルのみです。', + 'wanderer.error.invalid_response': + '応答がwandererから返されたものではありません。アドレスがwanderer自体を指しているか確認してください。', + 'wanderer.error.too_large': 'トレイルのファイルが10 MBを超えています。', + 'wanderer.error.server_error': 'wandererがエラーを返しました。しばらくしてからもう一度お試しください。', + 'wanderer.error.offline': 'オフラインです。wandererはオンライン時のみ検索できます。', + 'wanderer.error.preview_stale': '表示している間に、wandererでトレイルが変更されました。', + 'wanderer.error.already_imported': 'このトレイルはすでに旅行にあります。', + 'wanderer.error.no_track': 'このトレイルにはインポートできるトラックやルートがありません。', + 'wanderer.error.unknown': 'wandererで問題が発生しました。', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': '自分のトレイル', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'wandererインスタンスからトレイルをインポート', + 'wanderer.browser.backToList': '検索結果に戻る', + 'wanderer.browser.searchPlaceholder': 'トレイルを検索', + 'wanderer.browser.areaOff': 'この地図の範囲を検索', + 'wanderer.browser.areaOn': 'この地図の範囲を検索中', + 'wanderer.browser.areaOnHint': '表示中の地図の範囲にかかるトレイルのみ', + 'wanderer.browser.areaOffHint': '再びすべての場所を検索', + 'wanderer.browser.connectTitle': 'wandererを接続', + 'wanderer.browser.connect': '設定で接続', + 'wanderer.browser.empty': 'トレイルが見つかりません', + 'wanderer.browser.emptyArea': 'この地図の範囲にトレイルはありません', + 'wanderer.browser.emptyHint': '別の語句を試すか、地図を動かしてください。', + 'wanderer.browser.count': '{count}件のトレイル', + 'wanderer.browser.more': 'さらに読み込む', + 'wanderer.browser.public': '公開', + 'wanderer.browser.private': '非公開', + 'wanderer.browser.publicHint': 'wandererで公開:インスタンスの全員がそこで閲覧できます', + 'wanderer.browser.privateHint': 'wandererで非公開:そこでは自分だけが閲覧できます', + 'wanderer.browser.inTrip': 'この旅行内', + 'wanderer.browser.inTripHint': 'この旅行にインポート済み', + 'wanderer.browser.areaBadge': '地図の範囲', + 'wanderer.preview.loading': 'wandererからトレイルを読み込み中…', + 'wanderer.preview.measured': 'GPXファイルから', + 'wanderer.preview.points': 'トラックポイント', + 'wanderer.preview.noElevation': 'ファイルに標高データがないため、累積上昇量と累積下降量は不明です。', + 'wanderer.preview.openExternal': 'wandererで開く', + 'wanderer.preview.import': '旅行にインポート', + 'wanderer.preview.importNewVersion': '新しいバージョンとしてインポート', + 'wanderer.preview.openSnapshot': 'ツアーを開く', + 'wanderer.preview.openSnapshotShort': 'この旅行のツアーを開く', + 'wanderer.preview.newHint': + 'インポートすると、トレイルがこの旅行に独立したツアーとしてコピーされます。後でwandererで変更しても、こちらはそのままです。', + 'wanderer.preview.unchanged': + 'この旅行に「{name}」として{date}にインポート済みで、その後wandererでの変更はありません。', + 'wanderer.preview.changed': '{date}に「{name}」としてインポート済み。その後wandererで変更された項目:{parts}。', + 'wanderer.preview.editedLocally': 'この旅行のツアーは、その後ここで編集されています。', + 'wanderer.preview.newVersionHint': + '再度インポートすると、新しいバージョンが横に追加されます。旅行内のツアーはそのまま残ります。', + 'wanderer.preview.change.name': '名前', + 'wanderer.preview.change.description': '説明', + 'wanderer.preview.change.route': 'ルート', + 'wanderer.tourTitle': 'wandererツアー', + 'wanderer.import.done': '「{name}」をインポートしました', + 'wanderer.import.stale': 'wandererでトレイルが変更されました。最新のバージョンを表示しています。', +}; +export default wanderer; diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 0d152a6e0..c1e6eef56 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -667,6 +667,10 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': '애드온이 업데이트되었습니다', 'admin.addons.toast.error': '애드온 업데이트 실패', 'admin.addons.group.count': '{total}개 중 {enabled}개 활성화됨', + 'admin.addons.subOptions': '옵션 {count}개', + 'admin.addons.subOptionsOn': '{count}개 켜짐', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': '사용자가 자신의 wanderer 인스턴스를 연결하고 그 트레일을 투어로 가져올 수 있습니다', 'admin.addons.noAddons': '사용 가능한 애드온이 없습니다', 'admin.weather.title': '날씨 데이터', 'admin.weather.badge': '2026년 3월 24일부터', diff --git a/shared/src/i18n/ko/index.ts b/shared/src/i18n/ko/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/ko/index.ts +++ b/shared/src/i18n/ko/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts index f6e1d34aa..066a29f66 100644 --- a/shared/src/i18n/ko/tours.ts +++ b/shared/src/i18n/ko/tours.ts @@ -21,8 +21,56 @@ const tours: TranslationStrings = { 'tours.caution.tooltip': '고도 또는 측정 데이터가 불완전하여 주의가 필요한 상태로 가져왔습니다', 'tours.import.caution': '고도 또는 측정 데이터가 불완전하여 주의가 필요한 상태로 가져왔습니다', 'tours.import.success': 'GPX에서 투어 {count}개를 가져왔습니다', - 'tours.import.noTrack': '이 GPX 파일에서 트랙이나 경로를 찾을 수 없습니다', - 'tours.import.error': 'GPX 파일을 가져오지 못했습니다', + 'tours.import.noTrackIn': '{name}에서 트랙이나 경로를 찾을 수 없습니다', + 'tours.import.errorIn': '{name}을(를) 가져오지 못했습니다', + 'tours.import.button': 'GPX 가져오기', + 'tours.import.hint': 'GPX 파일을 이 여행의 투어로 가져옵니다', + 'tours.import.unsupported': '투어로 가져올 수 있는 파일은 GPX뿐입니다.', + 'tours.import.skipped': '트랙 {count}개는 이미 이 여행에 있어 건너뛰었습니다', + 'tours.import.allSkipped': '이 파일의 내용은 모두 이미 이 여행에 있습니다.', + 'tours.import.drop': 'GPX 파일을 놓아 가져오기', + 'tours.import.dropHint': '파일의 각 트랙이나 경로가 이 여행의 투어가 됩니다.', + 'tours.import.dropNoPermission': '이 여행에 투어를 추가할 수 없습니다.', + 'tours.import.importing': 'GPX 가져오는 중…', + 'tours.days.label': '여행 날짜', + 'tours.days.none': '아직 여행의 어느 날짜에도 배정되지 않았습니다.', + 'tours.days.short': '{days}일차', + 'tours.alreadyOnDay': '이 투어는 이미 해당 날짜에 있습니다.', + 'tours.conversion.title': '편집 가능한 버전', + 'tours.conversion.intro': + '가져온 항목은 기록된 트랙입니다. 편집 가능한 버전을 계획하면 TREK이 트랙에서 제어 지점을 가져와 경로를 새로 계산하고, 얼마나 가깝게 맞는지 보여 줍니다. 선택하기 전까지는 아무것도 바뀌지 않습니다.', + 'tours.conversion.replace': '가져온 항목 바꾸기', + 'tours.conversion.replaceHint': + '이 투어를 그 자리에서 다시 계획합니다. 일정과 메모는 그대로 유지되고, 기록된 트랙도 보관되어 나중에 되돌릴 수 있습니다.', + 'tours.conversion.adoptHint': '가져온 항목 옆에 새 투어를 계획합니다. 가져온 항목은 그대로 유지됩니다.', + 'tours.conversion.replacing': + '편집기에서 열었습니다. 저장하면 가져온 항목이 바뀝니다. 기록된 트랙은 계속 복원할 수 있습니다.', + 'tours.recorded.title': '기록된 트랙', + 'tours.recorded.hint': '이 투어는 가져온 항목을 바탕으로 다시 계획되었습니다. 기록 당시의 트랙은 보관되어 있습니다.', + 'tours.recorded.restore': '기록된 트랙 복원', + 'tours.recorded.confirmTitle': '기록된 트랙을 복원할까요?', + 'tours.recorded.confirmBody': + '경로 지점과 계산된 선이 삭제되고, 투어에 기록 당시의 트랙이 다시 표시됩니다. 일정과 메모는 그대로 유지됩니다.', + 'tours.recorded.restored': '기록된 트랙을 복원했습니다', + 'tours.recorded.failed': '기록된 트랙을 복원하지 못했습니다.', + 'tours.conversion.analyze': '편집 가능한 버전 계획', + 'tours.conversion.analyzing': '제어 지점 {count}개를 지나는 경로 계산 중…', + 'tours.conversion.failedTrack': '이 트랙은 너무 짧아 투어를 계획할 수 없습니다.', + 'tours.conversion.failedRouting': '제어 지점을 지나는 경로를 찾지 못했습니다.', + 'tours.conversion.controlPoints': '제어 지점', + 'tours.conversion.withinCorridor': '트랙에서 {m} m 이내', + 'tours.conversion.importLength': '가져온 항목', + 'tours.conversion.suggestionLength': '제안', + 'tours.conversion.maxDeviation': '최대 편차', + 'tours.conversion.legendImport': '가져온 항목', + 'tours.conversion.legendSuggestion': '제안', + 'tours.conversion.close': '제안이 트랙을 거의 그대로 따라갑니다. 경로를 새로 계산했으므로 완전히 같은 선은 아닙니다.', + 'tours.conversion.different': '제안이 긴 구간에서 트랙을 벗어납니다. 계획에 쓰기 전에 지도에서 확인하세요.', + 'tours.conversion.adopt': '새 투어로 계획', + 'tours.conversion.discard': '취소', + 'tours.conversion.adopted': '새 투어로 열었습니다. 유지하려면 저장하세요. 가져온 항목은 그대로 유지됩니다.', + 'tours.derivedFrom': '{name}에서 계획됨', + 'tours.derivedFromHint': '가져온 항목에서 경로를 새로 계산했으므로 기록된 트랙과 다를 수 있습니다.', 'tours.delete.confirmBody': '이 투어를 영구 삭제할까요? 경로, 편집 가능한 경유 지점 및 날짜 배정이 삭제됩니다. 이 작업은 되돌릴 수 없습니다.', 'tours.delete.bulkConfirmBody': @@ -36,9 +84,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': '최고 고도', 'tours.detail.ascent': '누적 상승 고도', 'tours.detail.descent': '누적 하강 고도', - 'tours.detail.difficulty': '난이도', 'tours.detail.source': '출처', - 'tours.detail.gpxReadOnly': 'GPX 투어는 조회만 가능하며, 아직 플래너에서 편집할 수 없습니다.', 'tours.detail.navigationHint': '길찾기는 탐방로 입구를 목적지로 엽니다. 투어 경로를 따라 안내하지는 않습니다.', 'tours.planner.title': '투어 계획', 'tours.planner.newTour': '새 투어 계획', diff --git a/shared/src/i18n/ko/wanderer.ts b/shared/src/i18n/ko/wanderer.ts new file mode 100644 index 000000000..c3751bdd7 --- /dev/null +++ b/shared/src/i18n/ko/wanderer.ts @@ -0,0 +1,91 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': '연결됨', + 'wanderer.notConnected': '연결되지 않음', + 'wanderer.intro': + '자신의 wanderer 인스턴스를 연결하면 그 트레일을 투어로 가져올 수 있습니다. 토큰은 서버에만 보관되며 다시 표시되지 않습니다.', + 'wanderer.url': '인스턴스 URL', + 'wanderer.token': 'API 토큰', + 'wanderer.tokenHint': 'wanderer의 설정, 계정, API 토큰에서 찾을 수 있습니다.', + 'wanderer.tokenInvalid': 'wanderer API 토큰이 아닙니다. 토큰은 wanderer_key_로 시작합니다.', + 'wanderer.allowInsecureTls': '자체 서명 인증서 허용', + 'wanderer.allowInsecureTlsHint': '내 네트워크에 있는 인스턴스에만 사용하세요.', + 'wanderer.test.button': '연결 테스트', + 'wanderer.test.ok': '연결되었습니다.', + 'wanderer.test.okCount': '연결되었습니다. 트레일 {count}개를 가져올 수 있습니다.', + 'wanderer.disconnect': '연결 해제', + 'wanderer.saved': 'wanderer 연결을 저장했습니다', + 'wanderer.disconnected': 'wanderer 연결을 삭제했습니다', + 'wanderer.loadFailed': 'wanderer 연결을 불러오지 못했습니다', + 'wanderer.privateIp': + '이 주소는 사설 네트워크에 있습니다. TREK이 접근할 수 없다면 관리자가 내부 네트워크를 허용해야 합니다.', + 'wanderer.error.disabled': '관리자가 투어에서 wanderer를 활성화하지 않았습니다.', + 'wanderer.error.not_connected': 'wanderer가 아직 연결되지 않았습니다.', + 'wanderer.error.invalid_url': '올바른 wanderer URL이 아닙니다.', + 'wanderer.error.blocked_address': + 'TREK이 이 주소에 접근할 수 없습니다. 내 네트워크의 인스턴스라면 관리자가 ALLOW_INTERNAL_NETWORK=true를 설정해야 합니다.', + 'wanderer.error.unreachable': 'wanderer에 연결할 수 없습니다. 주소와 인스턴스가 실행 중인지 확인하세요.', + 'wanderer.error.tls': '인스턴스의 인증서를 확인할 수 없습니다. 자체 서명 인증서라면 아래에서 허용하세요.', + 'wanderer.error.unauthorized': + 'wanderer가 API 토큰을 받아들이지 않았습니다. 토큰이 취소되었거나 만료되었을 수 있습니다.', + 'wanderer.error.forbidden': 'wanderer가 이 트레일에 대한 접근을 거부했습니다.', + 'wanderer.error.not_found': 'wanderer에 이 트레일이 더 이상 없습니다.', + 'wanderer.error.not_own_trail': '자신의 wanderer 트레일만 가져올 수 있습니다.', + 'wanderer.error.invalid_response': 'wanderer의 응답이 아닙니다. 주소가 wanderer 자체를 가리키는지 확인하세요.', + 'wanderer.error.too_large': '트레일 파일이 10 MB보다 큽니다.', + 'wanderer.error.server_error': 'wanderer가 오류로 응답했습니다. 나중에 다시 시도하세요.', + 'wanderer.error.offline': '오프라인 상태입니다. wanderer는 온라인에서만 검색할 수 있습니다.', + 'wanderer.error.preview_stale': '보고 있는 동안 wanderer에서 트레일이 변경되었습니다.', + 'wanderer.error.already_imported': '이 트레일은 이미 여행에 있습니다.', + 'wanderer.error.no_track': '이 트레일에는 가져올 트랙이나 경로가 없습니다.', + 'wanderer.error.unknown': 'wanderer에서 문제가 발생했습니다.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': '내 트레일', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'wanderer 인스턴스에서 트레일 가져오기', + 'wanderer.browser.backToList': '결과로 돌아가기', + 'wanderer.browser.searchPlaceholder': '트레일 검색', + 'wanderer.browser.areaOff': '이 지도 영역 검색', + 'wanderer.browser.areaOn': '이 지도 영역 검색 중', + 'wanderer.browser.areaOnHint': '보이는 지도 영역에 걸치는 트레일만', + 'wanderer.browser.areaOffHint': '다시 전체 검색', + 'wanderer.browser.connectTitle': 'wanderer 연결', + 'wanderer.browser.connect': '설정에서 연결', + 'wanderer.browser.empty': '트레일을 찾을 수 없습니다', + 'wanderer.browser.emptyArea': '이 지도 영역에 트레일이 없습니다', + 'wanderer.browser.emptyHint': '다른 검색어를 입력하거나 지도를 움직여 보세요.', + 'wanderer.browser.count': '트레일 {count}개', + 'wanderer.browser.more': '더 불러오기', + 'wanderer.browser.public': '공개', + 'wanderer.browser.private': '비공개', + 'wanderer.browser.publicHint': 'wanderer에서 공개: 인스턴스의 모든 사용자가 볼 수 있습니다', + 'wanderer.browser.privateHint': 'wanderer에서 비공개: 나만 볼 수 있습니다', + 'wanderer.browser.inTrip': '이 여행에 있음', + 'wanderer.browser.inTripHint': '이미 이 여행으로 가져왔습니다', + 'wanderer.browser.areaBadge': '지도 영역', + 'wanderer.preview.loading': 'wanderer에서 트레일을 읽는 중…', + 'wanderer.preview.measured': 'GPX 파일 기준', + 'wanderer.preview.points': '트랙 포인트', + 'wanderer.preview.noElevation': '파일에 고도 정보가 없어 상승 고도와 하강 고도를 알 수 없습니다.', + 'wanderer.preview.openExternal': 'wanderer에서 열기', + 'wanderer.preview.import': '여행으로 가져오기', + 'wanderer.preview.importNewVersion': '새 버전으로 가져오기', + 'wanderer.preview.openSnapshot': '투어 열기', + 'wanderer.preview.openSnapshotShort': '이 여행의 투어 열기', + 'wanderer.preview.newHint': + '가져오면 트레일이 이 여행에 별도의 투어로 복사됩니다. 이후 wanderer에서 변경해도 이 투어는 그대로입니다.', + 'wanderer.preview.unchanged': + '이 여행에 “{name}”(으)로 {date}에 가져왔으며, 그 이후 wanderer에서 변경되지 않았습니다.', + 'wanderer.preview.changed': '{date}에 “{name}”(으)로 가져왔습니다. 그 이후 wanderer에서 변경됨: {parts}.', + 'wanderer.preview.editedLocally': '이 여행의 투어는 그 이후 여기에서 편집되었습니다.', + 'wanderer.preview.newVersionHint': '다시 가져오면 옆에 새 버전이 추가됩니다. 여행에 있는 투어는 그대로 유지됩니다.', + 'wanderer.preview.change.name': '이름', + 'wanderer.preview.change.description': '설명', + 'wanderer.preview.change.route': '경로', + 'wanderer.tourTitle': 'wanderer 투어', + 'wanderer.import.done': '“{name}”을(를) 가져왔습니다', + 'wanderer.import.stale': 'wanderer에서 트레일이 변경되었습니다. 현재 버전을 표시합니다.', +}; +export default wanderer; diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 6496fb137..b16a45b74 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -658,6 +658,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Add-on bijgewerkt', 'admin.addons.toast.error': 'Add-on bijwerken mislukt', 'admin.addons.group.count': '{enabled} van {total} ingeschakeld', + 'admin.addons.subOptions': '{count} opties', + 'admin.addons.subOptions.one': '{count} optie', + 'admin.addons.subOptionsOn': '{count} aan', + 'admin.addons.subOptionsOn.one': '{count} aan', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Gebruikers kunnen hun eigen wanderer-instantie koppelen en de wandelroutes daarvan als tochten importeren', 'admin.addons.noAddons': 'Geen add-ons beschikbaar', 'admin.weather.title': 'Weergegevens', 'admin.weather.badge': 'Sinds 24 maart 2026', diff --git a/shared/src/i18n/nl/index.ts b/shared/src/i18n/nl/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/nl/index.ts +++ b/shared/src/i18n/nl/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts index 22b7c2417..7f16a15e7 100644 --- a/shared/src/i18n/nl/tours.ts +++ b/shared/src/i18n/nl/tours.ts @@ -22,8 +22,62 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Met voorbehoud geïmporteerd: hoogtegegevens of meetwaarden onvolledig', 'tours.import.success': '{count} tochten geïmporteerd uit GPX', 'tours.import.success.one': '{count} tocht geïmporteerd uit GPX', - 'tours.import.noTrack': 'Geen track of route gevonden in dit GPX-bestand', - 'tours.import.error': 'GPX-bestand kon niet worden geïmporteerd', + 'tours.import.noTrackIn': 'Geen track of route gevonden in {name}', + 'tours.import.errorIn': '{name} kon niet worden geïmporteerd', + 'tours.import.button': 'GPX importeren', + 'tours.import.hint': 'GPX-bestanden importeren als tochten van deze reis', + 'tours.import.unsupported': 'Alleen GPX-bestanden kunnen als tochten worden geïmporteerd.', + 'tours.import.skipped': '{count} tracks zaten al in deze reis en zijn overgeslagen', + 'tours.import.skipped.one': '{count} track zat al in deze reis en is overgeslagen', + 'tours.import.allSkipped': 'Alles in dat bestand zit al in deze reis.', + 'tours.import.drop': 'Zet GPX-bestanden hier neer om ze te importeren', + 'tours.import.dropHint': 'Elke track of route in een bestand wordt een tocht van deze reis.', + 'tours.import.dropNoPermission': 'Je kunt geen tochten aan deze reis toevoegen.', + 'tours.import.importing': 'GPX importeren…', + 'tours.days.label': 'Reisdagen', + 'tours.days.none': 'Nog op geen enkele dag van de reis.', + 'tours.days.short': 'Dagen {days}', + 'tours.days.short.one': 'Dag {days}', + 'tours.alreadyOnDay': 'Deze tocht staat al op die dag.', + 'tours.conversion.title': 'Bewerkbare versie', + 'tours.conversion.intro': + 'Een import is een opgenomen track. Plan een bewerkbare versie: TREK haalt er controlepunten uit, routeert ze opnieuw en laat zien hoe dicht dat in de buurt komt. Er verandert niets totdat je kiest.', + 'tours.conversion.replace': 'Import vervangen', + 'tours.conversion.replaceHint': + 'Plant deze tocht ter plekke opnieuw: de dagen en notities blijven, en de opgenomen track wordt bewaard zodat je ernaar terug kunt.', + 'tours.conversion.adoptHint': 'Plant een nieuwe tocht naast de import, die blijft zoals hij is.', + 'tours.conversion.replacing': + 'Geopend in de editor. Sla hem op om de import te vervangen; de opgenomen track blijft herstelbaar.', + 'tours.recorded.title': 'Opgenomen track', + 'tours.recorded.hint': + 'Deze tocht is opnieuw gepland op basis van een import. De track waarmee hij is opgenomen, is bewaard.', + 'tours.recorded.restore': 'Opgenomen track herstellen', + 'tours.recorded.confirmTitle': 'Opgenomen track herstellen?', + 'tours.recorded.confirmBody': + 'De routepunten en de gerouteerde lijn worden verwijderd, en de tocht toont weer de track waarmee hij is opgenomen. De dagen en notities blijven.', + 'tours.recorded.restored': 'Opgenomen track hersteld', + 'tours.recorded.failed': 'De opgenomen track kon niet worden hersteld.', + 'tours.conversion.analyze': 'Een bewerkbare versie plannen', + 'tours.conversion.analyzing': 'Route berekenen via {count} controlepunten…', + 'tours.conversion.analyzing.one': 'Route berekenen via {count} controlepunt…', + 'tours.conversion.failedTrack': 'Deze track is te kort om er een tocht van te plannen.', + 'tours.conversion.failedRouting': 'Er is geen route gevonden via de controlepunten.', + 'tours.conversion.controlPoints': 'Controlepunten', + 'tours.conversion.withinCorridor': 'Binnen {m} m van de track', + 'tours.conversion.importLength': 'Geïmporteerd', + 'tours.conversion.suggestionLength': 'Voorstel', + 'tours.conversion.maxDeviation': 'Grootste afwijking', + 'tours.conversion.legendImport': 'Geïmporteerd', + 'tours.conversion.legendSuggestion': 'Voorstel', + 'tours.conversion.close': + 'Het voorstel volgt de track nauwkeurig. Het is opnieuw gerouteerd, dus het is niet dezelfde lijn.', + 'tours.conversion.different': + 'Het voorstel wijkt over langere stukken van de track af. Controleer het op de kaart voordat je ermee plant.', + 'tours.conversion.adopt': 'Als nieuwe tocht plannen', + 'tours.conversion.discard': 'Verwerpen', + 'tours.conversion.adopted': 'Geopend als nieuwe tocht. Sla hem op om hem te bewaren; de import blijft zoals hij is.', + 'tours.derivedFrom': 'Gepland op basis van {name}', + 'tours.derivedFromHint': 'Opnieuw gerouteerd vanuit een import, dus kan afwijken van de opgenomen track.', 'tours.delete.confirmBody': 'Deze tour definitief verwijderen? De route, bewerkbare routepunten en dagtoewijzingen worden verwijderd. Dit kan niet ongedaan worden gemaakt.', 'tours.delete.bulkConfirmBody': @@ -38,9 +92,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Maximale hoogte', 'tours.detail.ascent': 'Hoogtemeters omhoog', 'tours.detail.descent': 'Hoogtemeters omlaag', - 'tours.detail.difficulty': 'Moeilijkheid', 'tours.detail.source': 'Bron', - 'tours.detail.gpxReadOnly': 'GPX-tochten kunnen alleen worden bekeken en nog niet in de planner worden bewerkt.', 'tours.detail.navigationHint': 'De navigatie opent het startpunt van het pad; ze begeleidt je niet langs de route.', 'tours.planner.title': 'Een tocht plannen', 'tours.planner.newTour': 'Nieuwe tocht plannen', diff --git a/shared/src/i18n/nl/wanderer.ts b/shared/src/i18n/nl/wanderer.ts new file mode 100644 index 000000000..9ac735442 --- /dev/null +++ b/shared/src/i18n/nl/wanderer.ts @@ -0,0 +1,96 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Gekoppeld', + 'wanderer.notConnected': 'Niet gekoppeld', + 'wanderer.intro': + 'Koppel je eigen wanderer-instantie om de wandelroutes daarvan als tochten te importeren. Het token blijft op de server en wordt nooit meer getoond.', + 'wanderer.url': 'Adres van de instantie', + 'wanderer.token': 'API-token', + 'wanderer.tokenHint': 'In wanderer onder Instellingen, Account, API-tokens.', + 'wanderer.tokenInvalid': 'Dat is geen wanderer-API-token. Een token begint met wanderer_key_.', + 'wanderer.allowInsecureTls': 'Zelfondertekend certificaat toestaan', + 'wanderer.allowInsecureTlsHint': 'Alleen voor een instantie in je eigen netwerk.', + 'wanderer.test.button': 'Verbinding testen', + 'wanderer.test.ok': 'Gekoppeld.', + 'wanderer.test.okCount': 'Gekoppeld. Je kunt {count} van je wandelroutes importeren.', + 'wanderer.test.okCount.one': 'Gekoppeld. Je kunt {count} van je wandelroutes importeren.', + 'wanderer.disconnect': 'Ontkoppelen', + 'wanderer.saved': 'wanderer-koppeling opgeslagen', + 'wanderer.disconnected': 'wanderer-koppeling verwijderd', + 'wanderer.loadFailed': 'De wanderer-koppeling kon niet worden geladen', + 'wanderer.privateIp': + 'Dit adres ligt in een privénetwerk. Als TREK het niet kan bereiken, moet de beheerder interne netwerken toestaan.', + 'wanderer.error.disabled': 'Je beheerder heeft wanderer niet ingeschakeld voor Tochten.', + 'wanderer.error.not_connected': 'wanderer is nog niet gekoppeld.', + 'wanderer.error.invalid_url': 'Dat adres is geen geldige wanderer-URL.', + 'wanderer.error.blocked_address': + 'TREK mag dat adres niet bereiken. Voor een instantie in je eigen netwerk moet de beheerder ALLOW_INTERNAL_NETWORK=true instellen.', + 'wanderer.error.unreachable': 'wanderer is niet bereikbaar. Controleer het adres en of de instantie draait.', + 'wanderer.error.tls': + 'Het certificaat van de instantie kon niet worden geverifieerd. Sta een zelfondertekend certificaat hieronder toe.', + 'wanderer.error.unauthorized': + 'wanderer heeft het API-token niet geaccepteerd. Het is misschien ingetrokken of verlopen.', + 'wanderer.error.forbidden': 'wanderer weigerde de toegang tot deze wandelroute.', + 'wanderer.error.not_found': 'Deze wandelroute bestaat niet meer in wanderer.', + 'wanderer.error.not_own_trail': 'Alleen je eigen wanderer-wandelroutes kunnen worden geïmporteerd.', + 'wanderer.error.invalid_response': + 'Het antwoord kwam niet van wanderer. Controleer of het adres naar wanderer zelf wijst.', + 'wanderer.error.too_large': 'Het bestand van de wandelroute is groter dan 10 MB.', + 'wanderer.error.server_error': 'wanderer antwoordde met een fout. Probeer het later opnieuw.', + 'wanderer.error.offline': 'Je bent offline. wanderer kan alleen online worden doorzocht.', + 'wanderer.error.preview_stale': 'De wandelroute is in wanderer gewijzigd terwijl je ernaar keek.', + 'wanderer.error.already_imported': 'Deze wandelroute zit al in de reis.', + 'wanderer.error.no_track': 'Deze wandelroute heeft geen track of route om te importeren.', + 'wanderer.error.unknown': 'Er ging iets mis met wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Je wandelroutes', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Een wandelroute uit je wanderer-instantie importeren', + 'wanderer.browser.backToList': 'Terug naar de resultaten', + 'wanderer.browser.searchPlaceholder': 'Wandelroutes zoeken', + 'wanderer.browser.areaOff': 'In dit kaartgebied zoeken', + 'wanderer.browser.areaOn': 'Zoeken in dit kaartgebied', + 'wanderer.browser.areaOnHint': 'Alleen wandelroutes die in het zichtbare deel van de kaart komen', + 'wanderer.browser.areaOffHint': 'Weer overal zoeken', + 'wanderer.browser.connectTitle': 'wanderer koppelen', + 'wanderer.browser.connect': 'Koppelen in de instellingen', + 'wanderer.browser.empty': 'Geen wandelroutes gevonden', + 'wanderer.browser.emptyArea': 'Geen wandelroutes in dit kaartgebied', + 'wanderer.browser.emptyHint': 'Probeer een ander woord of verschuif de kaart.', + 'wanderer.browser.count': '{count} wandelroutes', + 'wanderer.browser.count.one': '{count} wandelroute', + 'wanderer.browser.more': 'Meer laden', + 'wanderer.browser.public': 'Openbaar', + 'wanderer.browser.private': 'Privé', + 'wanderer.browser.publicHint': 'Openbaar in wanderer: iedereen op je instantie kan hem daar zien', + 'wanderer.browser.privateHint': 'Privé in wanderer: alleen jij kunt hem daar zien', + 'wanderer.browser.inTrip': 'In deze reis', + 'wanderer.browser.inTripHint': 'Al in deze reis geïmporteerd', + 'wanderer.browser.areaBadge': 'Kaartgebied', + 'wanderer.preview.loading': 'Wandelroute uit wanderer lezen…', + 'wanderer.preview.measured': 'Uit het GPX-bestand', + 'wanderer.preview.points': 'Trackpunten', + 'wanderer.preview.noElevation': 'Het bestand bevat geen hoogtegegevens, dus stijging en daling zijn onbekend.', + 'wanderer.preview.openExternal': 'Openen in wanderer', + 'wanderer.preview.import': 'In de reis importeren', + 'wanderer.preview.importNewVersion': 'Als nieuwe versie importeren', + 'wanderer.preview.openSnapshot': 'Tocht openen', + 'wanderer.preview.openSnapshotShort': 'Tocht in deze reis openen', + 'wanderer.preview.newHint': + 'Bij het importeren wordt de wandelroute als eigen tocht naar deze reis gekopieerd. Latere wijzigingen in wanderer laten hem ongemoeid.', + 'wanderer.preview.unchanged': + 'Al in deze reis als ‘{name}’, geïmporteerd op {date} en sindsdien ongewijzigd in wanderer.', + 'wanderer.preview.changed': 'Op {date} geïmporteerd als ‘{name}’. Sindsdien gewijzigd in wanderer: {parts}.', + 'wanderer.preview.editedLocally': 'De tocht in deze reis is hier sindsdien bewerkt.', + 'wanderer.preview.newVersionHint': + 'Opnieuw importeren voegt er een nieuwe versie naast toe. De tocht in de reis blijft zoals hij is.', + 'wanderer.preview.change.name': 'naam', + 'wanderer.preview.change.description': 'beschrijving', + 'wanderer.preview.change.route': 'route', // same-as-en + 'wanderer.tourTitle': 'wanderer-tocht', + 'wanderer.import.done': '‘{name}’ geïmporteerd', + 'wanderer.import.stale': 'De wandelroute is in wanderer gewijzigd. Dit is de huidige versie.', +}; +export default wanderer; diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index dad6f4f01..e89127403 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -674,6 +674,17 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Dodatek został zaktualizowany', 'admin.addons.toast.error': 'Nie udało się zaktualizować dodatku', 'admin.addons.group.count': '{enabled} z {total} włączonych', + 'admin.addons.subOptions': '{count} opcji', + 'admin.addons.subOptions.one': '{count} opcja', + 'admin.addons.subOptions.few': '{count} opcje', + 'admin.addons.subOptions.many': '{count} opcji', + 'admin.addons.subOptionsOn': 'włączone: {count}', + 'admin.addons.subOptionsOn.one': 'włączone: {count}', + 'admin.addons.subOptionsOn.few': 'włączone: {count}', + 'admin.addons.subOptionsOn.many': 'włączone: {count}', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Użytkownicy mogą połączyć własną instancję wanderer i importować z niej szlaki jako wycieczki', 'admin.addons.noAddons': 'Brak dostępnych dodatków', 'admin.weather.title': 'Dane pogodowe', 'admin.weather.badge': 'Od 24 marca 2026', diff --git a/shared/src/i18n/pl/index.ts b/shared/src/i18n/pl/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/pl/index.ts +++ b/shared/src/i18n/pl/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts index f4f3b1e54..20b2b0308 100644 --- a/shared/src/i18n/pl/tours.ts +++ b/shared/src/i18n/pl/tours.ts @@ -24,8 +24,68 @@ const tours: TranslationStrings = { 'tours.import.success.one': 'Zaimportowano {count} wycieczkę z GPX', 'tours.import.success.few': 'Zaimportowano {count} wycieczki z GPX', 'tours.import.success.many': 'Zaimportowano {count} wycieczek z GPX', - 'tours.import.noTrack': 'W tym pliku GPX nie znaleziono śladu ani trasy', - 'tours.import.error': 'Nie udało się zaimportować pliku GPX', + 'tours.import.noTrackIn': 'W pliku {name} nie znaleziono śladu ani trasy', + 'tours.import.errorIn': 'Nie udało się zaimportować pliku {name}', + 'tours.import.button': 'Importuj GPX', + 'tours.import.hint': 'Importuj pliki GPX jako wycieczki w tej podróży', + 'tours.import.unsupported': 'Jako wycieczki można importować tylko pliki GPX.', + 'tours.import.skipped': '{count} śladu było już w tej podróży, więc je pominięto', + 'tours.import.skipped.one': '{count} ślad był już w tej podróży, więc go pominięto', + 'tours.import.skipped.few': '{count} ślady były już w tej podróży, więc je pominięto', + 'tours.import.skipped.many': '{count} śladów było już w tej podróży, więc je pominięto', + 'tours.import.allSkipped': 'Wszystko z tego pliku jest już w tej podróży.', + 'tours.import.drop': 'Upuść pliki GPX, aby je zaimportować', + 'tours.import.dropHint': 'Każdy ślad lub trasa w pliku staje się wycieczką w tej podróży.', + 'tours.import.dropNoPermission': 'Nie możesz dodawać wycieczek do tej podróży.', + 'tours.import.importing': 'Importowanie GPX…', + 'tours.days.label': 'Dni podróży', + 'tours.days.none': 'Jeszcze nieprzypisana do żadnego dnia podróży.', + 'tours.days.short': 'Dni {days}', + 'tours.days.short.one': 'Dzień {days}', + 'tours.days.short.few': 'Dni {days}', + 'tours.days.short.many': 'Dni {days}', + 'tours.alreadyOnDay': 'Ta wycieczka jest już przypisana do tego dnia.', + 'tours.conversion.title': 'Wersja do edycji', + 'tours.conversion.intro': + 'Import to zarejestrowany ślad. Zaplanuj wersję do edycji: TREK pobierze z niego punkty kontrolne, wyznaczy przez nie trasę od nowa i pokaże, jak blisko udało się dojść. Nic się nie zmieni, dopóki nie wybierzesz.', + 'tours.conversion.replace': 'Zastąp import', + 'tours.conversion.replaceHint': + 'Planuje tę wycieczkę od nowa w tym samym miejscu: jej dni i notatki zostają, a zarejestrowany ślad jest zachowany, aby można było do niego wrócić.', + 'tours.conversion.adoptHint': 'Planuje nową wycieczkę obok importu, który pozostaje bez zmian.', + 'tours.conversion.replacing': + 'Otwarto w edytorze. Zapisz ją, aby zastąpić import; zarejestrowany ślad nadal będzie można przywrócić.', + 'tours.recorded.title': 'Zarejestrowany ślad', + 'tours.recorded.hint': + 'Ta wycieczka została zaplanowana od nowa na podstawie importu. Ślad, z którym została zarejestrowana, jest zachowany.', + 'tours.recorded.restore': 'Przywróć zarejestrowany ślad', + 'tours.recorded.confirmTitle': 'Przywrócić zarejestrowany ślad?', + 'tours.recorded.confirmBody': + 'Punkty trasy i wyznaczona linia zostaną usunięte, a wycieczka znów pokaże ślad, z którym została zarejestrowana. Jej dni i notatki zostają.', + 'tours.recorded.restored': 'Przywrócono zarejestrowany ślad', + 'tours.recorded.failed': 'Nie udało się przywrócić zarejestrowanego śladu.', + 'tours.conversion.analyze': 'Zaplanuj wersję do edycji', + 'tours.conversion.analyzing': 'Wyznaczanie trasy przez {count} punktu kontrolnego…', + 'tours.conversion.analyzing.one': 'Wyznaczanie trasy przez {count} punkt kontrolny…', + 'tours.conversion.analyzing.few': 'Wyznaczanie trasy przez {count} punkty kontrolne…', + 'tours.conversion.analyzing.many': 'Wyznaczanie trasy przez {count} punktów kontrolnych…', + 'tours.conversion.failedTrack': 'Ten ślad jest za krótki, aby zaplanować z niego wycieczkę.', + 'tours.conversion.failedRouting': 'Nie znaleziono trasy przez punkty kontrolne.', + 'tours.conversion.controlPoints': 'Punkty kontrolne', + 'tours.conversion.withinCorridor': 'Do {m} m od śladu', + 'tours.conversion.importLength': 'Import', // same-as-en + 'tours.conversion.suggestionLength': 'Propozycja', + 'tours.conversion.maxDeviation': 'Największe odchylenie', + 'tours.conversion.legendImport': 'Import', // same-as-en + 'tours.conversion.legendSuggestion': 'Propozycja', + 'tours.conversion.close': + 'Propozycja ściśle trzyma się śladu. Trasa jest wyznaczona od nowa, więc nie jest to ta sama linia.', + 'tours.conversion.different': + 'Propozycja na dłuższych odcinkach odbiega od śladu. Sprawdź ją na mapie, zanim zaczniesz z nią planować.', + 'tours.conversion.adopt': 'Zaplanuj jako nową wycieczkę', + 'tours.conversion.discard': 'Odrzuć', + 'tours.conversion.adopted': 'Otwarto jako nową wycieczkę. Zapisz ją, aby ją zachować; import pozostaje bez zmian.', + 'tours.derivedFrom': 'Zaplanowano na podstawie {name}', + 'tours.derivedFromHint': 'Trasa wyznaczona od nowa z importu, więc może się różnić od zarejestrowanego śladu.', 'tours.delete.confirmBody': 'Usunąć tę trasę na stałe? Jej przebieg, edytowalne punkty trasy i przypisania do dni zostaną usunięte. Tej czynności nie można cofnąć.', 'tours.delete.bulkConfirmBody': @@ -40,9 +100,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Maksymalna wysokość n.p.m.', 'tours.detail.ascent': 'Suma podejść', 'tours.detail.descent': 'Suma zejść', - 'tours.detail.difficulty': 'Trudność', 'tours.detail.source': 'Źródło', - 'tours.detail.gpxReadOnly': 'Wycieczki GPX można tylko przeglądać; nie można ich jeszcze edytować w planerze.', 'tours.detail.navigationHint': 'Nawigacja otwiera punkt początkowy szlaku; nie prowadzi wzdłuż trasy.', 'tours.planner.title': 'Zaplanuj wycieczkę', 'tours.planner.newTour': 'Zaplanuj nową wycieczkę', diff --git a/shared/src/i18n/pl/wanderer.ts b/shared/src/i18n/pl/wanderer.ts new file mode 100644 index 000000000..8f2a3878b --- /dev/null +++ b/shared/src/i18n/pl/wanderer.ts @@ -0,0 +1,98 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Połączono', + 'wanderer.notConnected': 'Nie połączono', + 'wanderer.intro': + 'Połącz własną instancję wanderer, aby importować jej szlaki jako wycieczki. Token pozostaje na serwerze i nie zostanie już ponownie wyświetlony.', + 'wanderer.url': 'Adres instancji', + 'wanderer.token': 'Token API', + 'wanderer.tokenHint': 'W aplikacji wanderer w sekcji Ustawienia, Konto, Tokeny API.', + 'wanderer.tokenInvalid': 'To nie jest token API aplikacji wanderer. Token zaczyna się od wanderer_key_.', + 'wanderer.allowInsecureTls': 'Zezwól na certyfikat z podpisem własnym', + 'wanderer.allowInsecureTlsHint': 'Tylko dla instancji w Twojej własnej sieci.', + 'wanderer.test.button': 'Testuj połączenie', + 'wanderer.test.ok': 'Połączono.', + 'wanderer.test.okCount': 'Połączono. Możesz zaimportować {count} swojego szlaku.', + 'wanderer.test.okCount.one': 'Połączono. Możesz zaimportować {count} swój szlak.', + 'wanderer.test.okCount.few': 'Połączono. Możesz zaimportować {count} swoje szlaki.', + 'wanderer.test.okCount.many': 'Połączono. Możesz zaimportować {count} swoich szlaków.', + 'wanderer.disconnect': 'Rozłącz', + 'wanderer.saved': 'Zapisano połączenie z wanderer', + 'wanderer.disconnected': 'Usunięto połączenie z wanderer', + 'wanderer.loadFailed': 'Nie udało się wczytać połączenia z wanderer', + 'wanderer.privateIp': + 'Ten adres należy do sieci prywatnej. Jeśli TREK nie może się z nim połączyć, administrator musi zezwolić na sieci wewnętrzne.', + 'wanderer.error.disabled': 'Administrator nie włączył wanderer dla wycieczek.', + 'wanderer.error.not_connected': 'wanderer nie jest jeszcze połączony.', + 'wanderer.error.invalid_url': 'Ten adres nie jest prawidłowym adresem URL wanderer.', + 'wanderer.error.blocked_address': + 'TREK nie może łączyć się z tym adresem. Dla instancji we własnej sieci administrator musi ustawić ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': 'Nie udało się połączyć z wanderer. Sprawdź adres i czy instancja działa.', + 'wanderer.error.tls': + 'Nie udało się zweryfikować certyfikatu instancji. Jeśli ma podpis własny, zezwól na niego poniżej.', + 'wanderer.error.unauthorized': 'wanderer nie zaakceptował tokenu API. Mógł zostać unieważniony lub wygasnąć.', + 'wanderer.error.forbidden': 'wanderer odmówił dostępu do tego szlaku.', + 'wanderer.error.not_found': 'Tego szlaku nie ma już w wanderer.', + 'wanderer.error.not_own_trail': 'Można importować tylko własne szlaki z wanderer.', + 'wanderer.error.invalid_response': + 'Odpowiedź nie pochodzi z wanderer. Sprawdź, czy adres wskazuje bezpośrednio na wanderer.', + 'wanderer.error.too_large': 'Plik szlaku jest większy niż 10 MB.', + 'wanderer.error.server_error': 'wanderer odpowiedział błędem. Spróbuj ponownie później.', + 'wanderer.error.offline': 'Jesteś offline. wanderer można przeszukiwać tylko online.', + 'wanderer.error.preview_stale': 'Szlak zmienił się w wanderer, gdy go przeglądałeś.', + 'wanderer.error.already_imported': 'Ten szlak jest już w podróży.', + 'wanderer.error.no_track': 'Ten szlak nie ma śladu ani trasy do zaimportowania.', + 'wanderer.error.unknown': 'Coś poszło nie tak z wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Twoje szlaki', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Importuj szlak ze swojej instancji wanderer', + 'wanderer.browser.backToList': 'Wróć do wyników', + 'wanderer.browser.searchPlaceholder': 'Szukaj szlaków', + 'wanderer.browser.areaOff': 'Szukaj w tym obszarze mapy', + 'wanderer.browser.areaOn': 'Wyszukiwanie w tym obszarze mapy', + 'wanderer.browser.areaOnHint': 'Tylko szlaki sięgające do widocznej części mapy', + 'wanderer.browser.areaOffHint': 'Szukaj znowu wszędzie', + 'wanderer.browser.connectTitle': 'Połącz wanderer', + 'wanderer.browser.connect': 'Połącz w ustawieniach', + 'wanderer.browser.empty': 'Nie znaleziono szlaków', + 'wanderer.browser.emptyArea': 'Brak szlaków w tym obszarze mapy', + 'wanderer.browser.emptyHint': 'Spróbuj innego słowa lub przesuń mapę.', + 'wanderer.browser.count': '{count} szlaku', + 'wanderer.browser.count.one': '{count} szlak', + 'wanderer.browser.count.few': '{count} szlaki', + 'wanderer.browser.count.many': '{count} szlaków', + 'wanderer.browser.more': 'Wczytaj więcej', + 'wanderer.browser.public': 'Publiczny', + 'wanderer.browser.private': 'Prywatny', + 'wanderer.browser.publicHint': 'Publiczny w wanderer: widzą go tam wszyscy w Twojej instancji', + 'wanderer.browser.privateHint': 'Prywatny w wanderer: widzisz go tam tylko Ty', + 'wanderer.browser.inTrip': 'W tej podróży', + 'wanderer.browser.inTripHint': 'Już zaimportowany do tej podróży', + 'wanderer.browser.areaBadge': 'Obszar mapy', + 'wanderer.preview.loading': 'Odczytywanie szlaku z wanderer…', + 'wanderer.preview.measured': 'Z pliku GPX', + 'wanderer.preview.points': 'Punkty śladu', + 'wanderer.preview.noElevation': 'Plik nie zawiera danych o wysokości, więc podejścia i zejścia są nieznane.', + 'wanderer.preview.openExternal': 'Otwórz w wanderer', + 'wanderer.preview.import': 'Importuj do podróży', + 'wanderer.preview.importNewVersion': 'Importuj jako nową wersję', + 'wanderer.preview.openSnapshot': 'Otwórz wycieczkę', + 'wanderer.preview.openSnapshotShort': 'Otwórz wycieczkę w tej podróży', + 'wanderer.preview.newHint': + 'Import kopiuje szlak do tej podróży jako osobną wycieczkę. Późniejsze zmiany w wanderer jej nie zmieniają.', + 'wanderer.preview.unchanged': + 'Już w tej podróży jako „{name}”, zaimportowano {date}, od tego czasu bez zmian w wanderer.', + 'wanderer.preview.changed': 'Zaimportowano {date} jako „{name}”. Od tego czasu zmieniono w wanderer: {parts}.', + 'wanderer.preview.editedLocally': 'Wycieczka w tej podróży była od tego czasu edytowana tutaj.', + 'wanderer.preview.newVersionHint': 'Ponowny import doda obok nową wersję. Wycieczka w podróży pozostanie bez zmian.', + 'wanderer.preview.change.name': 'nazwa', + 'wanderer.preview.change.description': 'opis', + 'wanderer.preview.change.route': 'trasa', + 'wanderer.tourTitle': 'Wycieczka z wanderer', + 'wanderer.import.done': 'Zaimportowano „{name}”', + 'wanderer.import.stale': 'Szlak zmienił się w wanderer. Oto aktualna wersja.', +}; +export default wanderer; diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 68cded24f..47dc99171 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -679,6 +679,17 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Дополнение обновлено', 'admin.addons.toast.error': 'Не удалось обновить дополнение', 'admin.addons.group.count': 'Включено {enabled} из {total}', + 'admin.addons.subOptions': '{count} параметра', + 'admin.addons.subOptions.one': '{count} параметр', + 'admin.addons.subOptions.few': '{count} параметра', + 'admin.addons.subOptions.many': '{count} параметров', + 'admin.addons.subOptionsOn': '{count} вкл.', + 'admin.addons.subOptionsOn.one': '{count} вкл.', + 'admin.addons.subOptionsOn.few': '{count} вкл.', + 'admin.addons.subOptionsOn.many': '{count} вкл.', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Пользователи могут подключить свой экземпляр wanderer и импортировать его маршруты как туры', 'admin.addons.noAddons': 'Нет доступных дополнений', 'admin.weather.title': 'Данные о погоде', 'admin.weather.badge': 'С 24 марта 2026', diff --git a/shared/src/i18n/ru/index.ts b/shared/src/i18n/ru/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/ru/index.ts +++ b/shared/src/i18n/ru/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts index 68e52909c..5ff6d3065 100644 --- a/shared/src/i18n/ru/tours.ts +++ b/shared/src/i18n/ru/tours.ts @@ -24,8 +24,68 @@ const tours: TranslationStrings = { 'tours.import.success.one': 'Импортирован {count} тур из GPX', 'tours.import.success.few': 'Импортировано {count} тура из GPX', 'tours.import.success.many': 'Импортировано {count} туров из GPX', - 'tours.import.noTrack': 'В этом файле GPX не найден трек или маршрут', - 'tours.import.error': 'Не удалось импортировать файл GPX', + 'tours.import.noTrackIn': 'В файле {name} не найден трек или маршрут', + 'tours.import.errorIn': 'Не удалось импортировать {name}', + 'tours.import.button': 'Импорт GPX', + 'tours.import.hint': 'Импортировать файлы GPX как туры этой поездки', + 'tours.import.unsupported': 'Как туры можно импортировать только файлы GPX.', + 'tours.import.skipped': 'Пропущено {count} трека: они уже есть в этой поездке', + 'tours.import.skipped.one': 'Пропущен {count} трек: он уже есть в этой поездке', + 'tours.import.skipped.few': 'Пропущено {count} трека: они уже есть в этой поездке', + 'tours.import.skipped.many': 'Пропущено {count} треков: они уже есть в этой поездке', + 'tours.import.allSkipped': 'Всё из этого файла уже есть в этой поездке.', + 'tours.import.drop': 'Перетащите сюда файлы GPX, чтобы импортировать их', + 'tours.import.dropHint': 'Каждый трек или маршрут в файле станет туром этой поездки.', + 'tours.import.dropNoPermission': 'Вы не можете добавлять туры в эту поездку.', + 'tours.import.importing': 'Импорт GPX…', + 'tours.days.label': 'Дни поездки', + 'tours.days.none': 'Пока не назначен ни на один день поездки.', + 'tours.days.short': 'Дни {days}', + 'tours.days.short.one': 'День {days}', + 'tours.days.short.few': 'Дни {days}', + 'tours.days.short.many': 'Дни {days}', + 'tours.alreadyOnDay': 'Этот тур уже назначен на этот день.', + 'tours.conversion.title': 'Редактируемая версия', + 'tours.conversion.intro': + 'Импорт представляет собой записанный трек. Спланируйте редактируемую версию: TREK возьмёт из него контрольные точки, заново проложит через них маршрут и покажет, насколько близко он подходит. Ничего не изменится, пока вы не выберете.', + 'tours.conversion.replace': 'Заменить импорт', + 'tours.conversion.replaceHint': + 'Планирует этот тур заново на том же месте: его дни и заметки сохраняются, а записанный трек остаётся, чтобы к нему можно было вернуться.', + 'tours.conversion.adoptHint': 'Планирует новый тур рядом с импортом, который остаётся без изменений.', + 'tours.conversion.replacing': + 'Открыто в редакторе. Сохраните его, чтобы заменить импорт; записанный трек можно будет восстановить.', + 'tours.recorded.title': 'Записанный трек', + 'tours.recorded.hint': 'Этот тур был спланирован заново поверх импорта. Трек, с которым он был записан, сохранён.', + 'tours.recorded.restore': 'Восстановить записанный трек', + 'tours.recorded.confirmTitle': 'Восстановить записанный трек?', + 'tours.recorded.confirmBody': + 'Путевые точки и проложенная линия будут удалены, и тур снова покажет трек, с которым он был записан. Его дни и заметки сохраняются.', + 'tours.recorded.restored': 'Записанный трек восстановлен', + 'tours.recorded.failed': 'Не удалось восстановить записанный трек.', + 'tours.conversion.analyze': 'Спланировать редактируемую версию', + 'tours.conversion.analyzing': 'Прокладка маршрута через {count} контрольной точки…', + 'tours.conversion.analyzing.one': 'Прокладка маршрута через {count} контрольную точку…', + 'tours.conversion.analyzing.few': 'Прокладка маршрута через {count} контрольные точки…', + 'tours.conversion.analyzing.many': 'Прокладка маршрута через {count} контрольных точек…', + 'tours.conversion.failedTrack': 'Этот трек слишком короткий, чтобы спланировать по нему тур.', + 'tours.conversion.failedRouting': 'Не удалось проложить маршрут через контрольные точки.', + 'tours.conversion.controlPoints': 'Контрольные точки', + 'tours.conversion.withinCorridor': 'В пределах {m} м от трека', + 'tours.conversion.importLength': 'Импорт', + 'tours.conversion.suggestionLength': 'Предложение', + 'tours.conversion.maxDeviation': 'Наибольшее отклонение', + 'tours.conversion.legendImport': 'Импорт', + 'tours.conversion.legendSuggestion': 'Предложение', + 'tours.conversion.close': + 'Предложение близко повторяет трек. Маршрут проложен заново, поэтому линия совпадает не полностью.', + 'tours.conversion.different': + 'На длинных участках предложение отходит от трека. Проверьте его на карте, прежде чем планировать по нему.', + 'tours.conversion.adopt': 'Спланировать как новый тур', + 'tours.conversion.discard': 'Отклонить', + 'tours.conversion.adopted': + 'Открыто как новый тур. Сохраните его, чтобы не потерять; импорт останется без изменений.', + 'tours.derivedFrom': 'Спланировано на основе {name}', + 'tours.derivedFromHint': 'Маршрут заново проложен по импорту, поэтому может отличаться от записанного трека.', 'tours.delete.confirmBody': 'Удалить этот маршрут навсегда? Его путь, редактируемые точки и назначения на дни будут удалены. Это действие нельзя отменить.', 'tours.delete.bulkConfirmBody': @@ -40,9 +100,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Максимальная высота', 'tours.detail.ascent': 'Набор высоты', 'tours.detail.descent': 'Сброс высоты', - 'tours.detail.difficulty': 'Сложность', 'tours.detail.source': 'Источник', - 'tours.detail.gpxReadOnly': 'Туры GPX доступны только для просмотра; редактировать их в планировщике пока нельзя.', 'tours.detail.navigationHint': 'Навигация открывает начальную точку тропы, но не ведёт по маршруту.', 'tours.planner.title': 'Спланировать тур', 'tours.planner.newTour': 'Спланировать новый тур', diff --git a/shared/src/i18n/ru/wanderer.ts b/shared/src/i18n/ru/wanderer.ts new file mode 100644 index 000000000..ac44f583a --- /dev/null +++ b/shared/src/i18n/ru/wanderer.ts @@ -0,0 +1,97 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Подключено', + 'wanderer.notConnected': 'Не подключено', + 'wanderer.intro': + 'Подключите свой экземпляр wanderer, чтобы импортировать его маршруты как туры. Токен хранится на сервере и больше никогда не показывается.', + 'wanderer.url': 'URL экземпляра', + 'wanderer.token': 'API-токен', + 'wanderer.tokenHint': 'В wanderer: «Настройки», «Аккаунт», «API-токены».', + 'wanderer.tokenInvalid': 'Это не API-токен wanderer. Токен начинается с wanderer_key_.', + 'wanderer.allowInsecureTls': 'Разрешить самоподписанный сертификат', + 'wanderer.allowInsecureTlsHint': 'Только для экземпляра в вашей собственной сети.', + 'wanderer.test.button': 'Проверить подключение', + 'wanderer.test.ok': 'Подключено.', + 'wanderer.test.okCount': 'Подключено. Маршрутов для импорта: {count}.', + 'wanderer.test.okCount.one': 'Подключено. Маршрутов для импорта: {count}.', + 'wanderer.test.okCount.few': 'Подключено. Маршрутов для импорта: {count}.', + 'wanderer.test.okCount.many': 'Подключено. Маршрутов для импорта: {count}.', + 'wanderer.disconnect': 'Отключить', + 'wanderer.saved': 'Подключение к wanderer сохранено', + 'wanderer.disconnected': 'Подключение к wanderer удалено', + 'wanderer.loadFailed': 'Не удалось загрузить подключение к wanderer', + 'wanderer.privateIp': + 'Этот адрес находится в частной сети. Если TREK не может к нему подключиться, администратор должен разрешить внутренние сети.', + 'wanderer.error.disabled': 'Администратор не включил wanderer для туров.', + 'wanderer.error.not_connected': 'wanderer ещё не подключён.', + 'wanderer.error.invalid_url': 'Этот адрес не является корректным URL wanderer.', + 'wanderer.error.blocked_address': + 'TREK не разрешено обращаться к этому адресу. Для экземпляра в вашей собственной сети администратор должен задать ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': 'Не удалось связаться с wanderer. Проверьте адрес и убедитесь, что экземпляр запущен.', + 'wanderer.error.tls': 'Не удалось проверить сертификат экземпляра. Если он самоподписанный, разрешите его ниже.', + 'wanderer.error.unauthorized': 'wanderer не принял API-токен. Возможно, он был отозван или срок его действия истёк.', + 'wanderer.error.forbidden': 'wanderer отказал в доступе к этому маршруту.', + 'wanderer.error.not_found': 'Этого маршрута больше нет в wanderer.', + 'wanderer.error.not_own_trail': 'Импортировать можно только собственные маршруты wanderer.', + 'wanderer.error.invalid_response': 'Ответ пришёл не от wanderer. Проверьте, что адрес указывает именно на wanderer.', + 'wanderer.error.too_large': 'Файл маршрута больше 10 МБ.', + 'wanderer.error.server_error': 'wanderer ответил ошибкой. Повторите попытку позже.', + 'wanderer.error.offline': 'Нет подключения к сети. Поиск в wanderer доступен только онлайн.', + 'wanderer.error.preview_stale': 'Маршрут изменился в wanderer, пока вы его просматривали.', + 'wanderer.error.already_imported': 'Этот маршрут уже есть в поездке.', + 'wanderer.error.no_track': 'В этом маршруте нет трека или линии маршрута для импорта.', + 'wanderer.error.unknown': 'При работе с wanderer произошла ошибка.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Ваши маршруты', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Импортировать маршрут из вашего экземпляра wanderer', + 'wanderer.browser.backToList': 'Назад к результатам', + 'wanderer.browser.searchPlaceholder': 'Поиск маршрутов', + 'wanderer.browser.areaOff': 'Искать в этой области карты', + 'wanderer.browser.areaOn': 'Поиск в этой области карты', + 'wanderer.browser.areaOnHint': 'Только маршруты, которые заходят в видимую часть карты', + 'wanderer.browser.areaOffHint': 'Снова искать везде', + 'wanderer.browser.connectTitle': 'Подключить wanderer', + 'wanderer.browser.connect': 'Подключить в настройках', + 'wanderer.browser.empty': 'Маршруты не найдены', + 'wanderer.browser.emptyArea': 'В этой области карты нет маршрутов', + 'wanderer.browser.emptyHint': 'Попробуйте другое слово или сдвиньте карту.', + 'wanderer.browser.count': '{count} маршрута', + 'wanderer.browser.count.one': '{count} маршрут', + 'wanderer.browser.count.few': '{count} маршрута', + 'wanderer.browser.count.many': '{count} маршрутов', + 'wanderer.browser.more': 'Загрузить ещё', + 'wanderer.browser.public': 'Общедоступный', + 'wanderer.browser.private': 'Личный', + 'wanderer.browser.publicHint': 'Общедоступный в wanderer: его видят все пользователи вашего экземпляра', + 'wanderer.browser.privateHint': 'Личный в wanderer: его видите только вы', + 'wanderer.browser.inTrip': 'В этой поездке', + 'wanderer.browser.inTripHint': 'Уже импортирован в эту поездку', + 'wanderer.browser.areaBadge': 'Область карты', + 'wanderer.preview.loading': 'Чтение маршрута из wanderer…', + 'wanderer.preview.measured': 'По файлу GPX', + 'wanderer.preview.points': 'Точки трека', + 'wanderer.preview.noElevation': 'В файле нет данных о высоте, поэтому набор и сброс высоты неизвестны.', + 'wanderer.preview.openExternal': 'Открыть в wanderer', + 'wanderer.preview.import': 'Импортировать в поездку', + 'wanderer.preview.importNewVersion': 'Импортировать как новую версию', + 'wanderer.preview.openSnapshot': 'Открыть тур', + 'wanderer.preview.openSnapshotShort': 'Открыть тур в этой поездке', + 'wanderer.preview.newHint': + 'При импорте маршрут копируется в эту поездку как отдельный тур. Последующие изменения в wanderer его не затронут.', + 'wanderer.preview.unchanged': + 'Уже есть в этой поездке как «{name}», импортирован {date} и с тех пор не изменялся в wanderer.', + 'wanderer.preview.changed': 'Импортирован {date} как «{name}». С тех пор изменено в wanderer: {parts}.', + 'wanderer.preview.editedLocally': 'С тех пор тур в этой поездке редактировался здесь.', + 'wanderer.preview.newVersionHint': + 'Повторный импорт добавит рядом новую версию. Тур в поездке останется без изменений.', + 'wanderer.preview.change.name': 'название', + 'wanderer.preview.change.description': 'описание', + 'wanderer.preview.change.route': 'трек', + 'wanderer.tourTitle': 'Тур wanderer', + 'wanderer.import.done': 'Импортировано: «{name}»', + 'wanderer.import.stale': 'Маршрут изменился в wanderer. Показана текущая версия.', +}; +export default wanderer; diff --git a/shared/src/i18n/sk/admin.ts b/shared/src/i18n/sk/admin.ts index c005acf42..684317a7d 100644 --- a/shared/src/i18n/sk/admin.ts +++ b/shared/src/i18n/sk/admin.ts @@ -728,6 +728,15 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Doplnok aktualizovaný', 'admin.addons.toast.error': 'Aktualizácia doplnku zlyhala', 'admin.addons.group.count': '{enabled} z {total} zapnuté', + 'admin.addons.subOptions': '{count} možností', + 'admin.addons.subOptions.one': '{count} možnosť', + 'admin.addons.subOptions.few': '{count} možnosti', + 'admin.addons.subOptionsOn': 'zapnuté: {count}', + 'admin.addons.subOptionsOn.one': 'zapnuté: {count}', + 'admin.addons.subOptionsOn.few': 'zapnuté: {count}', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Používatelia môžu pripojiť vlastnú inštanciu wanderer a importovať z nej trasy ako túry', 'admin.addons.noAddons': 'Žiadne doplnky nie sú dostupné', 'admin.weather.title': 'Dáta o počasí', 'admin.weather.badge': 'Od 24. marca 2026', diff --git a/shared/src/i18n/sk/index.ts b/shared/src/i18n/sk/index.ts index 49ed7a462..ec191b6db 100644 --- a/shared/src/i18n/sk/index.ts +++ b/shared/src/i18n/sk/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -70,6 +71,7 @@ const locale = { ...atlas, ...trip, ...tours, + ...wanderer, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts index b8b02eb34..c4e08392d 100644 --- a/shared/src/i18n/sk/tours.ts +++ b/shared/src/i18n/sk/tours.ts @@ -23,8 +23,64 @@ const tours: TranslationStrings = { 'tours.import.success': '{count} túr importovaných z GPX', 'tours.import.success.one': '{count} túra importovaná z GPX', 'tours.import.success.few': '{count} túry importované z GPX', - 'tours.import.noTrack': 'V tomto súbore GPX sa nenašla žiadna stopa ani trasa', - 'tours.import.error': 'Súbor GPX sa nepodarilo importovať', + 'tours.import.noTrackIn': 'V súbore {name} sa nenašla žiadna stopa ani trasa', + 'tours.import.errorIn': 'Súbor {name} sa nepodarilo importovať', + 'tours.import.button': 'Importovať GPX', + 'tours.import.hint': 'Importujte súbory GPX ako túry tejto cesty', + 'tours.import.unsupported': 'Ako túry sa dajú importovať iba súbory GPX.', + 'tours.import.skipped': '{count} stôp už v tejto ceste bolo, preto sa preskočili', + 'tours.import.skipped.one': '{count} stopa už v tejto ceste bola, preto sa preskočila', + 'tours.import.skipped.few': '{count} stopy už v tejto ceste boli, preto sa preskočili', + 'tours.import.allSkipped': 'Všetko z tohto súboru už v tejto ceste je.', + 'tours.import.drop': 'Pustite sem súbory GPX a importujte ich', + 'tours.import.dropHint': 'Každá stopa alebo trasa v súbore sa stane túrou tejto cesty.', + 'tours.import.dropNoPermission': 'Do tejto cesty nemôžete pridávať túry.', + 'tours.import.importing': 'Importuje sa GPX…', + 'tours.days.label': 'Dni cesty', + 'tours.days.none': 'Zatiaľ nie je priradená k žiadnemu dňu cesty.', + 'tours.days.short': 'Dni {days}', + 'tours.days.short.one': 'Deň {days}', + 'tours.days.short.few': 'Dni {days}', + 'tours.alreadyOnDay': 'Táto túra už je k tomuto dňu priradená.', + 'tours.conversion.title': 'Upraviteľná verzia', + 'tours.conversion.intro': + 'Import je zaznamenaná stopa. Naplánujte upraviteľnú verziu: TREK z nej vezme kontrolné body, nanovo cez ne vypočíta trasu a ukáže, ako blízko sa výsledok drží. Kým sa nerozhodnete, nič sa nezmení.', + 'tours.conversion.replace': 'Nahradiť import', + 'tours.conversion.replaceHint': + 'Naplánuje túto túru nanovo na mieste: jej dni a poznámky zostanú a zaznamenaná stopa sa uchová, aby ste sa k nej mohli vrátiť.', + 'tours.conversion.adoptHint': 'Naplánuje novú túru vedľa importu, ktorý zostane nezmenený.', + 'tours.conversion.replacing': + 'Otvorené v editore. Uložte ju, aby ste nahradili import; zaznamenanú stopu bude stále možné obnoviť.', + 'tours.recorded.title': 'Zaznamenaná stopa', + 'tours.recorded.hint': + 'Táto túra bola nanovo naplánovaná nad importom. Stopa, s ktorou bola zaznamenaná, je uchovaná.', + 'tours.recorded.restore': 'Obnoviť zaznamenanú stopu', + 'tours.recorded.confirmTitle': 'Obnoviť zaznamenanú stopu?', + 'tours.recorded.confirmBody': + 'Body trasy a vypočítaná línia sa zahodia a túra bude opäť zobrazovať stopu, s ktorou bola zaznamenaná. Jej dni a poznámky zostanú.', + 'tours.recorded.restored': 'Zaznamenaná stopa obnovená', + 'tours.recorded.failed': 'Zaznamenanú stopu sa nepodarilo obnoviť.', + 'tours.conversion.analyze': 'Naplánovať upraviteľnú verziu', + 'tours.conversion.analyzing': 'Výpočet trasy cez {count} kontrolných bodov…', + 'tours.conversion.analyzing.one': 'Výpočet trasy cez {count} kontrolný bod…', + 'tours.conversion.analyzing.few': 'Výpočet trasy cez {count} kontrolné body…', + 'tours.conversion.failedTrack': 'Táto stopa je príliš krátka na naplánovanie túry.', + 'tours.conversion.failedRouting': 'Cez kontrolné body sa nenašla žiadna trasa.', + 'tours.conversion.controlPoints': 'Kontrolné body', + 'tours.conversion.withinCorridor': 'Do {m} m od stopy', + 'tours.conversion.importLength': 'Import', // same-as-en + 'tours.conversion.suggestionLength': 'Návrh', + 'tours.conversion.maxDeviation': 'Najväčšia odchýlka', + 'tours.conversion.legendImport': 'Import', // same-as-en + 'tours.conversion.legendSuggestion': 'Návrh', + 'tours.conversion.close': 'Návrh sa drží blízko stopy. Trasa je vypočítaná nanovo, takže nejde o rovnakú líniu.', + 'tours.conversion.different': + 'Návrh sa na dlhších úsekoch od stopy odchyľuje. Skôr ako s ním začnete plánovať, skontrolujte ho na mape.', + 'tours.conversion.adopt': 'Naplánovať ako novú túru', + 'tours.conversion.discard': 'Zahodiť', + 'tours.conversion.adopted': 'Otvorené ako nová túra. Uložte ju, aby sa zachovala; import zostane nezmenený.', + 'tours.derivedFrom': 'Naplánované z {name}', + 'tours.derivedFromHint': 'Trasa je z importu vypočítaná nanovo, preto sa môže od zaznamenanej stopy líšiť.', 'tours.delete.confirmBody': 'Naozaj chcete tento výlet natrvalo odstrániť? Odstráni sa jeho trasa, upraviteľné body a priradenia k dňom. Túto akciu nemožno vrátiť späť.', 'tours.delete.bulkConfirmBody': @@ -38,9 +94,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Maximálna nadmorská výška', 'tours.detail.ascent': 'Stúpanie', 'tours.detail.descent': 'Klesanie', - 'tours.detail.difficulty': 'Náročnosť', 'tours.detail.source': 'Zdroj', - 'tours.detail.gpxReadOnly': 'Túry z GPX možno len prezerať, zatiaľ ich nemožno upravovať v plánovači.', 'tours.detail.navigationHint': 'Navigácia otvorí začiatok túry; nevedie po trase.', 'tours.planner.title': 'Naplánovať túru', 'tours.planner.newTour': 'Naplánovať novú túru', diff --git a/shared/src/i18n/sk/wanderer.ts b/shared/src/i18n/sk/wanderer.ts new file mode 100644 index 000000000..767116e1f --- /dev/null +++ b/shared/src/i18n/sk/wanderer.ts @@ -0,0 +1,95 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Pripojené', + 'wanderer.notConnected': 'Nepripojené', + 'wanderer.intro': + 'Pripojte vlastnú inštanciu wanderer a importujte z nej trasy ako túry. Token zostane na serveri a už sa nikdy nezobrazí.', + 'wanderer.url': 'Adresa inštancie', + 'wanderer.token': 'Token API', + 'wanderer.tokenHint': 'V aplikácii wanderer v časti Nastavenia, Účet, Tokeny API.', + 'wanderer.tokenInvalid': 'Toto nie je token API aplikácie wanderer. Token začína na wanderer_key_.', + 'wanderer.allowInsecureTls': 'Povoliť certifikát podpísaný sám sebou', + 'wanderer.allowInsecureTlsHint': 'Iba pre inštanciu vo vašej vlastnej sieti.', + 'wanderer.test.button': 'Otestovať pripojenie', + 'wanderer.test.ok': 'Pripojené.', + 'wanderer.test.okCount': 'Pripojené. Môžete importovať {count} svojich trás.', + 'wanderer.test.okCount.one': 'Pripojené. Môžete importovať {count} svoju trasu.', + 'wanderer.test.okCount.few': 'Pripojené. Môžete importovať {count} svoje trasy.', + 'wanderer.disconnect': 'Odpojiť', + 'wanderer.saved': 'Pripojenie k wanderer uložené', + 'wanderer.disconnected': 'Pripojenie k wanderer odstránené', + 'wanderer.loadFailed': 'Pripojenie k wanderer sa nepodarilo načítať', + 'wanderer.privateIp': + 'Táto adresa je v privátnej sieti. Ak sa k nej TREK nedokáže pripojiť, administrátor musí povoliť interné siete.', + 'wanderer.error.disabled': 'Váš administrátor nepovolil wanderer pre túry.', + 'wanderer.error.not_connected': 'wanderer ešte nie je pripojený.', + 'wanderer.error.invalid_url': 'Táto adresa nie je platná URL inštancie wanderer.', + 'wanderer.error.blocked_address': + 'TREK sa k tejto adrese nesmie pripojiť. Pre inštanciu vo vašej vlastnej sieti musí administrátor nastaviť ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': 'wanderer nie je dostupný. Skontrolujte adresu a či inštancia beží.', + 'wanderer.error.tls': 'Certifikát inštancie sa nepodarilo overiť. Ak je podpísaný sám sebou, povoľte ho nižšie.', + 'wanderer.error.unauthorized': 'wanderer neprijal token API. Mohol byť zrušený alebo mu vypršala platnosť.', + 'wanderer.error.forbidden': 'wanderer odmietol prístup k tejto trase.', + 'wanderer.error.not_found': 'Táto trasa už v aplikácii wanderer neexistuje.', + 'wanderer.error.not_own_trail': 'Importovať sa dajú iba vaše vlastné trasy z aplikácie wanderer.', + 'wanderer.error.invalid_response': + 'Odpoveď neprišla z aplikácie wanderer. Skontrolujte, či adresa vedie priamo na wanderer.', + 'wanderer.error.too_large': 'Súbor trasy je väčší ako 10 MB.', + 'wanderer.error.server_error': 'wanderer odpovedal chybou. Skúste to neskôr.', + 'wanderer.error.offline': 'Ste offline. V aplikácii wanderer sa dá vyhľadávať iba online.', + 'wanderer.error.preview_stale': 'Trasa sa v aplikácii wanderer zmenila, kým ste si ju prezerali.', + 'wanderer.error.already_imported': 'Táto trasa už v ceste je.', + 'wanderer.error.no_track': 'Táto trasa neobsahuje žiadnu stopu na import.', + 'wanderer.error.unknown': 'Pri práci s aplikáciou wanderer sa niečo pokazilo.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Vaše trasy', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Importovať trasu z vašej inštancie wanderer', + 'wanderer.browser.backToList': 'Späť na výsledky', + 'wanderer.browser.searchPlaceholder': 'Hľadať trasy', + 'wanderer.browser.areaOff': 'Hľadať v tejto oblasti mapy', + 'wanderer.browser.areaOn': 'Hľadá sa v tejto oblasti mapy', + 'wanderer.browser.areaOnHint': 'Iba trasy, ktoré zasahujú do viditeľnej časti mapy', + 'wanderer.browser.areaOffHint': 'Znova hľadať všade', + 'wanderer.browser.connectTitle': 'Pripojiť wanderer', + 'wanderer.browser.connect': 'Pripojiť v nastaveniach', + 'wanderer.browser.empty': 'Nenašli sa žiadne trasy', + 'wanderer.browser.emptyArea': 'V tejto oblasti mapy nie sú žiadne trasy', + 'wanderer.browser.emptyHint': 'Skúste iné slovo alebo posuňte mapu.', + 'wanderer.browser.count': '{count} trás', + 'wanderer.browser.count.one': '{count} trasa', + 'wanderer.browser.count.few': '{count} trasy', + 'wanderer.browser.more': 'Načítať ďalšie', + 'wanderer.browser.public': 'Verejná', + 'wanderer.browser.private': 'Súkromná', + 'wanderer.browser.publicHint': 'Verejná v aplikácii wanderer: vidí ju tam každý na vašej inštancii', + 'wanderer.browser.privateHint': 'Súkromná v aplikácii wanderer: vidíte ju tam iba vy', + 'wanderer.browser.inTrip': 'V tejto ceste', + 'wanderer.browser.inTripHint': 'Už importované do tejto cesty', + 'wanderer.browser.areaBadge': 'Oblasť mapy', + 'wanderer.preview.loading': 'Načítava sa trasa z aplikácie wanderer…', + 'wanderer.preview.measured': 'Zo súboru GPX', + 'wanderer.preview.points': 'Body stopy', + 'wanderer.preview.noElevation': 'Súbor neobsahuje výškové údaje, preto stúpanie a klesanie nie sú známe.', + 'wanderer.preview.openExternal': 'Otvoriť v aplikácii wanderer', + 'wanderer.preview.import': 'Importovať do cesty', + 'wanderer.preview.importNewVersion': 'Importovať ako novú verziu', + 'wanderer.preview.openSnapshot': 'Otvoriť túru', + 'wanderer.preview.openSnapshotShort': 'Otvoriť túru v tejto ceste', + 'wanderer.preview.newHint': + 'Import skopíruje trasu do tejto cesty ako samostatnú túru. Neskoršie zmeny v aplikácii wanderer ju neovplyvnia.', + 'wanderer.preview.unchanged': + 'Už je v tejto ceste ako „{name}“, importované {date} a odvtedy sa v aplikácii wanderer nezmenilo.', + 'wanderer.preview.changed': 'Importované {date} ako „{name}“. Odvtedy sa v aplikácii wanderer zmenilo: {parts}.', + 'wanderer.preview.editedLocally': 'Túra v tejto ceste bola odvtedy upravená tu.', + 'wanderer.preview.newVersionHint': 'Opätovný import pridá vedľa nej novú verziu. Túra v ceste zostane nezmenená.', + 'wanderer.preview.change.name': 'názov', + 'wanderer.preview.change.description': 'popis', + 'wanderer.preview.change.route': 'trasa', + 'wanderer.tourTitle': 'Túra z aplikácie wanderer', + 'wanderer.import.done': 'Importované „{name}“', + 'wanderer.import.stale': 'Trasa sa v aplikácii wanderer zmenila. Tu je aktuálna verzia.', +}; +export default wanderer; diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 6263ba486..22c08fd6d 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -726,6 +726,12 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Tillägget har uppdaterats', 'admin.addons.toast.error': 'Det gick inte att uppdatera tillägget', 'admin.addons.group.count': '{enabled} av {total} aktiverade', + 'admin.addons.subOptions': '{count} alternativ', + 'admin.addons.subOptions.one': '{count} alternativ', + 'admin.addons.subOptionsOn': '{count} aktiva', + 'admin.addons.subOptionsOn.one': '{count} aktiv', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': 'Användare kan ansluta sin egen wanderer-instans och importera dess leder som turer', 'admin.addons.noAddons': 'Inga tillägg tillgängliga', 'admin.weather.title': 'Väderdata', 'admin.weather.badge': 'Sedan den 24 mars 2026', diff --git a/shared/src/i18n/sv/index.ts b/shared/src/i18n/sv/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/sv/index.ts +++ b/shared/src/i18n/sv/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts index 44c9c9858..68034940b 100644 --- a/shared/src/i18n/sv/tours.ts +++ b/shared/src/i18n/sv/tours.ts @@ -22,8 +22,60 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Importerad med förbehåll: ofullständiga höjddata eller mätvärden', 'tours.import.success': '{count} turer importerade från GPX', 'tours.import.success.one': '{count} tur importerad från GPX', - 'tours.import.noTrack': 'Varken något spår eller någon rutt hittades i GPX-filen', - 'tours.import.error': 'Det gick inte att importera GPX-filen', + 'tours.import.noTrackIn': 'Inget spår eller ingen rutt hittades i {name}', + 'tours.import.errorIn': 'Det gick inte att importera {name}', + 'tours.import.button': 'Importera GPX', + 'tours.import.hint': 'Importera GPX-filer som turer i den här resan', + 'tours.import.unsupported': 'Endast GPX-filer kan importeras som turer.', + 'tours.import.skipped': '{count} spår fanns redan i den här resan och hoppades över', + 'tours.import.skipped.one': '{count} spår fanns redan i den här resan och hoppades över', + 'tours.import.allSkipped': 'Allt i den filen finns redan i den här resan.', + 'tours.import.drop': 'Släpp GPX-filer för att importera dem', + 'tours.import.dropHint': 'Varje spår eller rutt i en fil blir en tur i den här resan.', + 'tours.import.dropNoPermission': 'Du kan inte lägga till turer i den här resan.', + 'tours.import.importing': 'Importerar GPX…', + 'tours.days.label': 'Resdagar', + 'tours.days.none': 'Inte på någon dag i resan än.', + 'tours.days.short': 'Dagar {days}', + 'tours.days.short.one': 'Dag {days}', + 'tours.alreadyOnDay': 'Den här turen finns redan på den dagen.', + 'tours.conversion.title': 'Redigerbar version', + 'tours.conversion.intro': + 'En import är ett inspelat spår. Planera en redigerbar version: TREK tar kontrollpunkter från det, ruttar dem på nytt och visar hur nära det kommer. Inget ändras förrän du väljer.', + 'tours.conversion.replace': 'Ersätt importen', + 'tours.conversion.replaceHint': + 'Planerar om den här turen på plats: dess dagar och anteckningar finns kvar, och det inspelade spåret sparas så att du kan gå tillbaka till det.', + 'tours.conversion.adoptHint': 'Planerar en ny tur bredvid importen, som förblir som den är.', + 'tours.conversion.replacing': + 'Öppnad i redigeraren. Spara den för att ersätta importen; det inspelade spåret går fortfarande att återställa.', + 'tours.recorded.title': 'Inspelat spår', + 'tours.recorded.hint': 'Den här turen har planerats om utifrån en import. Spåret den spelades in med finns kvar.', + 'tours.recorded.restore': 'Återställ inspelat spår', + 'tours.recorded.confirmTitle': 'Återställa det inspelade spåret?', + 'tours.recorded.confirmBody': + 'Ruttpunkterna och den ruttade linjen tas bort, och turen visar åter spåret den spelades in med. Dess dagar och anteckningar finns kvar.', + 'tours.recorded.restored': 'Inspelat spår återställt', + 'tours.recorded.failed': 'Det inspelade spåret kunde inte återställas.', + 'tours.conversion.analyze': 'Planera en redigerbar version', + 'tours.conversion.analyzing': 'Beräknar rutt genom {count} kontrollpunkter…', + 'tours.conversion.analyzing.one': 'Beräknar rutt genom {count} kontrollpunkt…', + 'tours.conversion.failedTrack': 'Det här spåret är för kort för att planera en tur från.', + 'tours.conversion.failedRouting': 'Ingen rutt hittades genom kontrollpunkterna.', + 'tours.conversion.controlPoints': 'Kontrollpunkter', + 'tours.conversion.withinCorridor': 'Inom {m} m från spåret', + 'tours.conversion.importLength': 'Import', // same-as-en + 'tours.conversion.suggestionLength': 'Förslag', + 'tours.conversion.maxDeviation': 'Största avvikelse', + 'tours.conversion.legendImport': 'Import', // same-as-en + 'tours.conversion.legendSuggestion': 'Förslag', + 'tours.conversion.close': 'Förslaget följer spåret noga. Rutten är nyberäknad, så det är inte samma linje.', + 'tours.conversion.different': + 'Förslaget lämnar spåret på längre sträckor. Kontrollera det på kartan innan du planerar med det.', + 'tours.conversion.adopt': 'Planera som ny tur', + 'tours.conversion.discard': 'Kasta', + 'tours.conversion.adopted': 'Öppnad som ny tur. Spara den för att behålla den; importen förblir som den är.', + 'tours.derivedFrom': 'Planerad från {name}', + 'tours.derivedFromHint': 'Nyberäknad från en import, så den kan skilja sig från det inspelade spåret.', 'tours.delete.confirmBody': 'Ta bort den här turen permanent? Rutten, de redigerbara rutpunkterna och dagstilldelningarna tas bort. Åtgärden kan inte ångras.', 'tours.delete.bulkConfirmBody': @@ -38,9 +90,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Högsta höjd', 'tours.detail.ascent': 'Höjdmeter uppför', 'tours.detail.descent': 'Höjdmeter nedför', - 'tours.detail.difficulty': 'Svårighetsgrad', 'tours.detail.source': 'Källa', - 'tours.detail.gpxReadOnly': 'GPX-turer kan bara visas och kan ännu inte redigeras i planeraren.', 'tours.detail.navigationHint': 'Navigeringen öppnar ledens startpunkt; den vägleder inte längs rutten.', 'tours.planner.title': 'Planera en tur', 'tours.planner.newTour': 'Planera ny tur', diff --git a/shared/src/i18n/sv/wanderer.ts b/shared/src/i18n/sv/wanderer.ts new file mode 100644 index 000000000..a36564080 --- /dev/null +++ b/shared/src/i18n/sv/wanderer.ts @@ -0,0 +1,95 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Ansluten', + 'wanderer.notConnected': 'Inte ansluten', + 'wanderer.intro': + 'Anslut din egen wanderer-instans för att importera dess leder som turer. Token stannar på servern och visas aldrig igen.', + 'wanderer.url': 'Instansens URL', + 'wanderer.token': 'API-token', + 'wanderer.tokenHint': 'I wanderer under Inställningar, Konto, API-tokens.', + 'wanderer.tokenInvalid': 'Det där är ingen API-token för wanderer. En token börjar med wanderer_key_.', + 'wanderer.allowInsecureTls': 'Tillåt ett självsignerat certifikat', + 'wanderer.allowInsecureTlsHint': 'Endast för en instans i ditt eget nätverk.', + 'wanderer.test.button': 'Testa anslutningen', + 'wanderer.test.ok': 'Ansluten.', + 'wanderer.test.okCount': 'Ansluten. Du kan importera {count} av dina leder.', + 'wanderer.test.okCount.one': 'Ansluten. Du kan importera {count} av dina leder.', + 'wanderer.disconnect': 'Koppla från', + 'wanderer.saved': 'wanderer-anslutningen sparad', + 'wanderer.disconnected': 'wanderer-anslutningen borttagen', + 'wanderer.loadFailed': 'Det gick inte att läsa in wanderer-anslutningen', + 'wanderer.privateIp': + 'Den här adressen finns i ett privat nätverk. Om TREK inte kan nå den måste administratören tillåta interna nätverk.', + 'wanderer.error.disabled': 'Din administratör har inte aktiverat wanderer för turer.', + 'wanderer.error.not_connected': 'wanderer är inte anslutet ännu.', + 'wanderer.error.invalid_url': 'Den adressen är ingen giltig wanderer-URL.', + 'wanderer.error.blocked_address': + 'TREK får inte nå den adressen. För en instans i ditt eget nätverk måste administratören ange ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': 'Det gick inte att nå wanderer. Kontrollera adressen och att instansen körs.', + 'wanderer.error.tls': + 'Instansens certifikat kunde inte verifieras. Om det är självsignerat kan du tillåta det nedan.', + 'wanderer.error.unauthorized': 'wanderer godkände inte API-token. Den kan ha återkallats eller gått ut.', + 'wanderer.error.forbidden': 'wanderer nekade åtkomst till den här leden.', + 'wanderer.error.not_found': 'Den här leden finns inte längre i wanderer.', + 'wanderer.error.not_own_trail': 'Endast dina egna leder i wanderer kan importeras.', + 'wanderer.error.invalid_response': + 'Svaret kom inte från wanderer. Kontrollera att adressen pekar på själva wanderer.', + 'wanderer.error.too_large': 'Ledens fil är större än 10 MB.', + 'wanderer.error.server_error': 'wanderer svarade med ett fel. Försök igen senare.', + 'wanderer.error.offline': 'Du är offline. wanderer kan bara sökas online.', + 'wanderer.error.preview_stale': 'Leden ändrades i wanderer medan du tittade på den.', + 'wanderer.error.already_imported': 'Den här leden finns redan i resan.', + 'wanderer.error.no_track': 'Den här leden har inget spår eller ingen rutt att importera.', + 'wanderer.error.unknown': 'Något gick fel med wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Dina leder', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Importera en led från din wanderer-instans', + 'wanderer.browser.backToList': 'Tillbaka till resultaten', + 'wanderer.browser.searchPlaceholder': 'Sök leder', + 'wanderer.browser.areaOff': 'Sök i det här kartområdet', + 'wanderer.browser.areaOn': 'Söker i det här kartområdet', + 'wanderer.browser.areaOnHint': 'Endast leder som når in i den del av kartan du ser', + 'wanderer.browser.areaOffHint': 'Sök överallt igen', + 'wanderer.browser.connectTitle': 'Anslut wanderer', + 'wanderer.browser.connect': 'Anslut i inställningarna', + 'wanderer.browser.empty': 'Inga leder hittades', + 'wanderer.browser.emptyArea': 'Inga leder i det här kartområdet', + 'wanderer.browser.emptyHint': 'Prova ett annat ord eller flytta kartan.', + 'wanderer.browser.count': '{count} leder', + 'wanderer.browser.count.one': '{count} led', + 'wanderer.browser.more': 'Läs in fler', + 'wanderer.browser.public': 'Offentlig', + 'wanderer.browser.private': 'Privat', + 'wanderer.browser.publicHint': 'Offentlig i wanderer: alla på din instans kan se den där', + 'wanderer.browser.privateHint': 'Privat i wanderer: bara du kan se den där', + 'wanderer.browser.inTrip': 'I den här resan', + 'wanderer.browser.inTripHint': 'Redan importerad till den här resan', + 'wanderer.browser.areaBadge': 'Kartområde', + 'wanderer.preview.loading': 'Läser leden från wanderer…', + 'wanderer.preview.measured': 'Från GPX-filen', + 'wanderer.preview.points': 'Spårpunkter', + 'wanderer.preview.noElevation': 'Filen saknar höjddata, så stigning och nedstigning är okända.', + 'wanderer.preview.openExternal': 'Öppna i wanderer', + 'wanderer.preview.import': 'Importera till resan', + 'wanderer.preview.importNewVersion': 'Importera som ny version', + 'wanderer.preview.openSnapshot': 'Öppna turen', + 'wanderer.preview.openSnapshotShort': 'Öppna turen i den här resan', + 'wanderer.preview.newHint': + 'Importen kopierar leden till den här resan som en egen tur. Senare ändringar i wanderer påverkar den inte.', + 'wanderer.preview.unchanged': + 'Finns redan i den här resan som ”{name}”, importerad {date} och oförändrad i wanderer sedan dess.', + 'wanderer.preview.changed': 'Importerad {date} som ”{name}”. Ändrat i wanderer sedan dess: {parts}.', + 'wanderer.preview.editedLocally': 'Turen i den här resan har redigerats här sedan dess.', + 'wanderer.preview.newVersionHint': + 'En ny import lägger till en ny version bredvid. Turen i resan förblir som den är.', + 'wanderer.preview.change.name': 'namn', + 'wanderer.preview.change.description': 'beskrivning', + 'wanderer.preview.change.route': 'rutt', + 'wanderer.tourTitle': 'wanderer-tur', + 'wanderer.import.done': 'Importerade ”{name}”', + 'wanderer.import.stale': 'Leden har ändrats i wanderer. Här är den aktuella versionen.', +}; +export default wanderer; diff --git a/shared/src/i18n/th/admin.ts b/shared/src/i18n/th/admin.ts index d63da8b25..8fbf8b8e6 100644 --- a/shared/src/i18n/th/admin.ts +++ b/shared/src/i18n/th/admin.ts @@ -628,6 +628,11 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'อัพเดตแอดออนแล้ว', 'admin.addons.toast.error': 'ไม่สามารถอัปเดต addon', 'admin.addons.group.count': 'เปิดใช้งาน {enabled} จาก {total} แล้ว', + 'admin.addons.subOptions': '{count} ตัวเลือก', + 'admin.addons.subOptionsOn': 'เปิดอยู่ {count}', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'ผู้ใช้เชื่อมต่ออินสแตนซ์ wanderer ของตนเองและนำเข้าเส้นทางเดินจากที่นั่นเป็นทัวร์ได้', 'admin.addons.noAddons': 'ไม่มีส่วนเสริมให้ใช้งาน', 'admin.weather.title': 'ข้อมูลสภาพอากาศ', 'admin.weather.badge': 'ตั้งแต่วันที่ 24 มีนาคม 2569', diff --git a/shared/src/i18n/th/index.ts b/shared/src/i18n/th/index.ts index 49ed7a462..ec191b6db 100644 --- a/shared/src/i18n/th/index.ts +++ b/shared/src/i18n/th/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -70,6 +71,7 @@ const locale = { ...atlas, ...trip, ...tours, + ...wanderer, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts index bc6e26bd1..50f191d76 100644 --- a/shared/src/i18n/th/tours.ts +++ b/shared/src/i18n/th/tours.ts @@ -21,8 +21,55 @@ const tours: TranslationStrings = { 'tours.caution.tooltip': 'ข้อมูลระดับความสูงหรือค่าสถิติไม่ครบถ้วน ใช้ข้อมูลที่นำเข้าด้วยความระมัดระวัง', 'tours.import.caution': 'นำเข้าแล้ว แต่ข้อมูลระดับความสูงหรือค่าสถิติไม่ครบถ้วน โปรดใช้ด้วยความระมัดระวัง', 'tours.import.success': 'นำเข้าทัวร์ {count} รายการจาก GPX แล้ว', - 'tours.import.noTrack': 'ไม่พบแทร็กหรือเส้นทางในไฟล์ GPX นี้', - 'tours.import.error': 'ไม่สามารถนำเข้าไฟล์ GPX ได้', + 'tours.import.noTrackIn': 'ไม่พบแทร็กหรือเส้นทางใน {name}', + 'tours.import.errorIn': 'ไม่สามารถนำเข้า {name} ได้', + 'tours.import.button': 'นำเข้า GPX', + 'tours.import.hint': 'นำเข้าไฟล์ GPX เป็นทัวร์ของทริปนี้', + 'tours.import.unsupported': 'นำเข้าเป็นทัวร์ได้เฉพาะไฟล์ GPX เท่านั้น', + 'tours.import.skipped': 'ข้ามแทร็ก {count} รายการเพราะมีอยู่ในทริปนี้แล้ว', + 'tours.import.allSkipped': 'ทุกอย่างในไฟล์นั้นมีอยู่ในทริปนี้แล้ว', + 'tours.import.drop': 'วางไฟล์ GPX ที่นี่เพื่อนำเข้า', + 'tours.import.dropHint': 'แทร็กหรือเส้นทางแต่ละรายการในไฟล์จะกลายเป็นทัวร์ของทริปนี้', + 'tours.import.dropNoPermission': 'คุณไม่สามารถเพิ่มทัวร์ลงในทริปนี้ได้', + 'tours.import.importing': 'กำลังนำเข้า GPX…', + 'tours.days.label': 'วันในทริป', + 'tours.days.none': 'ยังไม่ได้อยู่ในวันใดของทริป', + 'tours.days.short': 'วันที่ {days}', + 'tours.alreadyOnDay': 'ทัวร์นี้อยู่ในวันนั้นแล้ว', + 'tours.conversion.title': 'เวอร์ชันที่แก้ไขได้', + 'tours.conversion.intro': + 'รายการนำเข้าคือแทร็กที่บันทึกไว้ วางแผนเวอร์ชันที่แก้ไขได้: TREK จะดึงจุดควบคุมจากแทร็กนั้น คำนวณเส้นทางใหม่ และแสดงว่าผลลัพธ์ใกล้เคียงเพียงใด จะไม่มีอะไรเปลี่ยนจนกว่าคุณจะเลือก', + 'tours.conversion.replace': 'แทนที่รายการนำเข้า', + 'tours.conversion.replaceHint': + 'วางแผนทัวร์นี้ใหม่ในที่เดิม: วันและบันทึกจะยังคงอยู่ และแทร็กที่บันทึกไว้จะถูกเก็บไว้เพื่อให้คุณย้อนกลับไปได้', + 'tours.conversion.adoptHint': 'วางแผนทัวร์ใหม่ข้างรายการนำเข้า ซึ่งจะยังคงเหมือนเดิม', + 'tours.conversion.replacing': 'เปิดในตัวแก้ไขแล้ว บันทึกเพื่อแทนที่รายการนำเข้า แทร็กที่บันทึกไว้ยังกู้คืนได้', + 'tours.recorded.title': 'แทร็กที่บันทึกไว้', + 'tours.recorded.hint': 'ทัวร์นี้ถูกวางแผนใหม่จากรายการนำเข้า แทร็กที่ใช้บันทึกทัวร์นี้ยังถูกเก็บไว้', + 'tours.recorded.restore': 'กู้คืนแทร็กที่บันทึกไว้', + 'tours.recorded.confirmTitle': 'กู้คืนแทร็กที่บันทึกไว้หรือไม่', + 'tours.recorded.confirmBody': + 'จุดแวะและเส้นที่คำนวณไว้จะถูกลบ และทัวร์จะแสดงแทร็กที่ใช้บันทึกอีกครั้ง วันและบันทึกจะยังคงอยู่', + 'tours.recorded.restored': 'กู้คืนแทร็กที่บันทึกไว้แล้ว', + 'tours.recorded.failed': 'ไม่สามารถกู้คืนแทร็กที่บันทึกไว้ได้', + 'tours.conversion.analyze': 'วางแผนเวอร์ชันที่แก้ไขได้', + 'tours.conversion.analyzing': 'กำลังคำนวณเส้นทางผ่านจุดควบคุม {count} จุด…', + 'tours.conversion.failedTrack': 'แทร็กนี้สั้นเกินกว่าจะใช้วางแผนทัวร์ได้', + 'tours.conversion.failedRouting': 'ไม่พบเส้นทางที่ผ่านจุดควบคุม', + 'tours.conversion.controlPoints': 'จุดควบคุม', + 'tours.conversion.withinCorridor': 'ห่างจากแทร็กไม่เกิน {m} เมตร', + 'tours.conversion.importLength': 'รายการนำเข้า', + 'tours.conversion.suggestionLength': 'ข้อเสนอแนะ', + 'tours.conversion.maxDeviation': 'ความคลาดเคลื่อนสูงสุด', + 'tours.conversion.legendImport': 'รายการนำเข้า', + 'tours.conversion.legendSuggestion': 'ข้อเสนอแนะ', + 'tours.conversion.close': 'เส้นทางที่เสนอใกล้เคียงกับแทร็กมาก แต่คำนวณใหม่ จึงไม่ใช่เส้นเดียวกันทุกประการ', + 'tours.conversion.different': 'เส้นทางที่เสนอออกนอกแทร็กเป็นช่วงยาว โปรดตรวจสอบบนแผนที่ก่อนนำไปวางแผน', + 'tours.conversion.adopt': 'วางแผนเป็นทัวร์ใหม่', + 'tours.conversion.discard': 'ทิ้ง', + 'tours.conversion.adopted': 'เปิดเป็นทัวร์ใหม่แล้ว บันทึกเพื่อเก็บไว้ รายการที่นำเข้าจะยังคงเหมือนเดิม', + 'tours.derivedFrom': 'วางแผนจาก {name}', + 'tours.derivedFromHint': 'คำนวณเส้นทางใหม่จากรายการที่นำเข้า จึงอาจต่างจากแทร็กที่บันทึกไว้', 'tours.delete.confirmBody': 'ลบทัวร์นี้อย่างถาวรหรือไม่? เส้นทาง จุดแวะที่แก้ไขได้ และการจัดตารางลงวันจะถูกลบ การดำเนินการนี้ไม่สามารถยกเลิกได้', 'tours.delete.bulkConfirmBody': @@ -36,9 +83,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'ระดับความสูงสูงสุด', 'tours.detail.ascent': 'ความสูงสะสมขาขึ้น', 'tours.detail.descent': 'ความสูงสะสมขาลง', - 'tours.detail.difficulty': 'ระดับความยาก', 'tours.detail.source': 'แหล่งข้อมูล', - 'tours.detail.gpxReadOnly': 'ทัวร์ GPX ดูได้อย่างเดียว ยังแก้ไขในตัววางแผนไม่ได้', 'tours.detail.navigationHint': 'การนำทางจะเปิดจุดเริ่มต้นทางเดิน ไม่ได้นำทางตามเส้นทางทัวร์', 'tours.planner.title': 'วางแผนทัวร์', 'tours.planner.newTour': 'วางแผนทัวร์ใหม่', diff --git a/shared/src/i18n/th/wanderer.ts b/shared/src/i18n/th/wanderer.ts new file mode 100644 index 000000000..8ea1e0ed9 --- /dev/null +++ b/shared/src/i18n/th/wanderer.ts @@ -0,0 +1,89 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'เชื่อมต่อแล้ว', + 'wanderer.notConnected': 'ยังไม่ได้เชื่อมต่อ', + 'wanderer.intro': + 'เชื่อมต่ออินสแตนซ์ wanderer ของคุณเองเพื่อนำเข้าเส้นทางเดินเป็นทัวร์ โทเค็นจะถูกเก็บไว้บนเซิร์ฟเวอร์และจะไม่แสดงอีก', + 'wanderer.url': 'URL ของอินสแตนซ์', + 'wanderer.token': 'โทเค็น API', + 'wanderer.tokenHint': 'อยู่ใน wanderer ที่การตั้งค่า บัญชี และโทเค็น API', + 'wanderer.tokenInvalid': 'นี่ไม่ใช่โทเค็น API ของ wanderer โทเค็นจะขึ้นต้นด้วย wanderer_key_', + 'wanderer.allowInsecureTls': 'อนุญาตใบรับรองที่ลงนามด้วยตนเอง', + 'wanderer.allowInsecureTlsHint': 'สำหรับอินสแตนซ์ในเครือข่ายของคุณเองเท่านั้น', + 'wanderer.test.button': 'ทดสอบการเชื่อมต่อ', + 'wanderer.test.ok': 'เชื่อมต่อแล้ว', + 'wanderer.test.okCount': 'เชื่อมต่อแล้ว คุณนำเข้าเส้นทางเดินของคุณได้ {count} รายการ', + 'wanderer.disconnect': 'ตัดการเชื่อมต่อ', + 'wanderer.saved': 'บันทึกการเชื่อมต่อ wanderer แล้ว', + 'wanderer.disconnected': 'ลบการเชื่อมต่อ wanderer แล้ว', + 'wanderer.loadFailed': 'ไม่สามารถโหลดการเชื่อมต่อ wanderer ได้', + 'wanderer.privateIp': 'ที่อยู่นี้อยู่ในเครือข่ายส่วนตัว หาก TREK เข้าถึงไม่ได้ ผู้ดูแลระบบต้องอนุญาตเครือข่ายภายใน', + 'wanderer.error.disabled': 'ผู้ดูแลระบบยังไม่ได้เปิดใช้ wanderer สำหรับทัวร์', + 'wanderer.error.not_connected': 'ยังไม่ได้เชื่อมต่อ wanderer', + 'wanderer.error.invalid_url': 'ที่อยู่นี้ไม่ใช่ URL ของ wanderer ที่ถูกต้อง', + 'wanderer.error.blocked_address': + 'TREK ไม่ได้รับอนุญาตให้เข้าถึงที่อยู่นี้ สำหรับอินสแตนซ์ในเครือข่ายของคุณเอง ผู้ดูแลระบบต้องตั้งค่า ALLOW_INTERNAL_NETWORK=true', + 'wanderer.error.unreachable': 'ไม่สามารถเข้าถึง wanderer ได้ โปรดตรวจสอบที่อยู่และดูว่าอินสแตนซ์ทำงานอยู่', + 'wanderer.error.tls': 'ไม่สามารถตรวจสอบใบรับรองของอินสแตนซ์ได้ หากเป็นใบรับรองที่ลงนามด้วยตนเอง ให้อนุญาตด้านล่าง', + 'wanderer.error.unauthorized': 'wanderer ไม่ยอมรับโทเค็น API นี้ โทเค็นอาจถูกเพิกถอนหรือหมดอายุแล้ว', + 'wanderer.error.forbidden': 'wanderer ปฏิเสธการเข้าถึงเส้นทางเดินนี้', + 'wanderer.error.not_found': 'ไม่มีเส้นทางเดินนี้ใน wanderer แล้ว', + 'wanderer.error.not_own_trail': 'นำเข้าได้เฉพาะเส้นทางเดินของคุณเองใน wanderer เท่านั้น', + 'wanderer.error.invalid_response': 'คำตอบไม่ได้มาจาก wanderer โปรดตรวจสอบว่าที่อยู่ชี้ไปที่ wanderer โดยตรง', + 'wanderer.error.too_large': 'ไฟล์เส้นทางเดินมีขนาดใหญ่กว่า 10 MB', + 'wanderer.error.server_error': 'wanderer ตอบกลับด้วยข้อผิดพลาด โปรดลองอีกครั้งภายหลัง', + 'wanderer.error.offline': 'คุณออฟไลน์อยู่ ค้นหา wanderer ได้เฉพาะเมื่อออนไลน์เท่านั้น', + 'wanderer.error.preview_stale': 'เส้นทางเดินถูกเปลี่ยนใน wanderer ขณะที่คุณกำลังดูอยู่', + 'wanderer.error.already_imported': 'เส้นทางเดินนี้อยู่ในทริปแล้ว', + 'wanderer.error.no_track': 'เส้นทางเดินนี้ไม่มีแทร็กหรือเส้นทางให้นำเข้า', + 'wanderer.error.unknown': 'เกิดข้อผิดพลาดกับ wanderer', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'เส้นทางเดินของคุณ', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'นำเข้าเส้นทางเดินจากอินสแตนซ์ wanderer ของคุณ', + 'wanderer.browser.backToList': 'กลับไปที่ผลการค้นหา', + 'wanderer.browser.searchPlaceholder': 'ค้นหาเส้นทางเดิน', + 'wanderer.browser.areaOff': 'ค้นหาในพื้นที่แผนที่นี้', + 'wanderer.browser.areaOn': 'กำลังค้นหาในพื้นที่แผนที่นี้', + 'wanderer.browser.areaOnHint': 'เฉพาะเส้นทางเดินที่ผ่านส่วนของแผนที่ที่คุณเห็น', + 'wanderer.browser.areaOffHint': 'ค้นหาทุกที่อีกครั้ง', + 'wanderer.browser.connectTitle': 'เชื่อมต่อ wanderer', + 'wanderer.browser.connect': 'เชื่อมต่อในการตั้งค่า', + 'wanderer.browser.empty': 'ไม่พบเส้นทางเดิน', + 'wanderer.browser.emptyArea': 'ไม่มีเส้นทางเดินในพื้นที่แผนที่นี้', + 'wanderer.browser.emptyHint': 'ลองใช้คำอื่นหรือเลื่อนแผนที่', + 'wanderer.browser.count': '{count} เส้นทางเดิน', + 'wanderer.browser.more': 'โหลดเพิ่ม', + 'wanderer.browser.public': 'สาธารณะ', + 'wanderer.browser.private': 'ส่วนตัว', + 'wanderer.browser.publicHint': 'สาธารณะใน wanderer: ทุกคนในอินสแตนซ์ของคุณเห็นได้ที่นั่น', + 'wanderer.browser.privateHint': 'ส่วนตัวใน wanderer: มีเพียงคุณที่เห็นได้ที่นั่น', + 'wanderer.browser.inTrip': 'อยู่ในทริปนี้', + 'wanderer.browser.inTripHint': 'นำเข้าในทริปนี้แล้ว', + 'wanderer.browser.areaBadge': 'พื้นที่แผนที่', + 'wanderer.preview.loading': 'กำลังอ่านเส้นทางเดินจาก wanderer…', + 'wanderer.preview.measured': 'จากไฟล์ GPX', + 'wanderer.preview.points': 'จุดแทร็ก', + 'wanderer.preview.noElevation': 'ไฟล์ไม่มีข้อมูลระดับความสูง จึงไม่ทราบความสูงสะสมขาขึ้นและขาลง', + 'wanderer.preview.openExternal': 'เปิดใน wanderer', + 'wanderer.preview.import': 'นำเข้าในทริป', + 'wanderer.preview.importNewVersion': 'นำเข้าเป็นเวอร์ชันใหม่', + 'wanderer.preview.openSnapshot': 'เปิดทัวร์', + 'wanderer.preview.openSnapshotShort': 'เปิดทัวร์ในทริปนี้', + 'wanderer.preview.newHint': + 'การนำเข้าจะคัดลอกเส้นทางเดินมาไว้ในทริปนี้เป็นทัวร์แยกต่างหาก การเปลี่ยนแปลงใน wanderer ภายหลังจะไม่มีผลกับทัวร์นี้', + 'wanderer.preview.unchanged': + 'อยู่ในทริปนี้แล้วในชื่อ “{name}” นำเข้าเมื่อ {date} และไม่มีการเปลี่ยนแปลงใน wanderer ตั้งแต่นั้น', + 'wanderer.preview.changed': 'นำเข้าเมื่อ {date} ในชื่อ “{name}” มีการเปลี่ยนแปลงใน wanderer ตั้งแต่นั้น: {parts}', + 'wanderer.preview.editedLocally': 'ทัวร์ในทริปนี้ถูกแก้ไขที่นี่ตั้งแต่นั้น', + 'wanderer.preview.newVersionHint': 'การนำเข้าอีกครั้งจะเพิ่มเวอร์ชันใหม่ไว้ข้างกัน ทัวร์ในทริปจะยังคงเหมือนเดิม', + 'wanderer.preview.change.name': 'ชื่อ', + 'wanderer.preview.change.description': 'คำอธิบาย', + 'wanderer.preview.change.route': 'เส้นทาง', + 'wanderer.tourTitle': 'ทัวร์ wanderer', + 'wanderer.import.done': 'นำเข้า “{name}” แล้ว', + 'wanderer.import.stale': 'เส้นทางเดินถูกเปลี่ยนใน wanderer นี่คือเวอร์ชันปัจจุบัน', +}; +export default wanderer; diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 4ac6e3a61..71c6a2e0a 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -714,6 +714,13 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Eklenti güncellendi', 'admin.addons.toast.error': 'Eklenti güncellenemedi', 'admin.addons.group.count': '{total} eklentiden {enabled} tanesi etkin', + 'admin.addons.subOptions': '{count} seçenek', + 'admin.addons.subOptions.one': '{count} seçenek', + 'admin.addons.subOptionsOn': '{count} açık', + 'admin.addons.subOptionsOn.one': '{count} açık', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Kullanıcılar kendi wanderer sunucularını bağlayıp parkurlarını tur olarak içe aktarabilir', 'admin.addons.noAddons': 'Kullanılabilir eklenti yok', 'admin.weather.title': 'Hava Durumu Verileri', 'admin.weather.badge': "24 Mart 2026'dan beri", diff --git a/shared/src/i18n/tr/index.ts b/shared/src/i18n/tr/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/tr/index.ts +++ b/shared/src/i18n/tr/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts index 7594ee6b4..d8cb039f6 100644 --- a/shared/src/i18n/tr/tours.ts +++ b/shared/src/i18n/tr/tours.ts @@ -22,8 +22,60 @@ const tours: TranslationStrings = { 'tours.import.caution': 'Çekinceyle içe aktarıldı – yükseklik verileri veya ölçümler eksik', 'tours.import.success': 'GPX dosyasından {count} tur içe aktarıldı', 'tours.import.success.one': 'GPX dosyasından {count} tur içe aktarıldı', - 'tours.import.noTrack': 'Bu GPX dosyasında iz veya rota bulunamadı', - 'tours.import.error': 'GPX dosyası içe aktarılamadı', + 'tours.import.noTrackIn': '{name} içinde iz veya rota bulunamadı', + 'tours.import.errorIn': '{name} içe aktarılamadı', + 'tours.import.button': 'GPX içe aktar', + 'tours.import.hint': 'GPX dosyalarını bu seyahatin turları olarak içe aktarın', + 'tours.import.unsupported': 'Tur olarak yalnızca GPX dosyaları içe aktarılabilir.', + 'tours.import.skipped': '{count} iz bu seyahatte zaten vardı ve atlandı', + 'tours.import.skipped.one': '{count} iz bu seyahatte zaten vardı ve atlandı', + 'tours.import.allSkipped': 'Bu dosyadaki her şey bu seyahatte zaten var.', + 'tours.import.drop': 'İçe aktarmak için GPX dosyalarını bırakın', + 'tours.import.dropHint': 'Bir dosyadaki her iz veya rota bu seyahatin bir turu olur.', + 'tours.import.dropNoPermission': 'Bu seyahate tur ekleyemezsiniz.', + 'tours.import.importing': 'GPX içe aktarılıyor…', + 'tours.days.label': 'Seyahat günleri', + 'tours.days.none': 'Henüz seyahatin hiçbir gününde değil.', + 'tours.days.short': 'Günler {days}', + 'tours.days.short.one': 'Gün {days}', + 'tours.alreadyOnDay': 'Bu tur o günde zaten var.', + 'tours.conversion.title': 'Düzenlenebilir sürüm', + 'tours.conversion.intro': + 'Bir içe aktarım kaydedilmiş bir izdir. Düzenlenebilir bir sürüm planlayın: TREK ondan kontrol noktaları alır, bunları yeniden rotalar ve sonucun ne kadar yakın olduğunu gösterir. Siz seçene kadar hiçbir şey değişmez.', + 'tours.conversion.replace': 'İçe aktarımı değiştir', + 'tours.conversion.replaceHint': + 'Bu turu yerinde yeniden planlar: günleri ve notları kalır, kaydedilmiş iz ise ona geri dönebilmeniz için saklanır.', + 'tours.conversion.adoptHint': 'İçe aktarımın yanına yeni bir tur planlar; içe aktarım olduğu gibi kalır.', + 'tours.conversion.replacing': + 'Düzenleyicide açıldı. İçe aktarımı değiştirmek için kaydedin; kaydedilmiş iz geri yüklenebilir kalır.', + 'tours.recorded.title': 'Kaydedilmiş iz', + 'tours.recorded.hint': 'Bu tur bir içe aktarımın üzerine yeniden planlandı. Kaydedildiği iz saklanıyor.', + 'tours.recorded.restore': 'Kaydedilmiş izi geri yükle', + 'tours.recorded.confirmTitle': 'Kaydedilmiş iz geri yüklensin mi?', + 'tours.recorded.confirmBody': + 'Rota noktaları ve rotalanmış çizgi kaldırılır, tur yeniden kaydedildiği izi gösterir. Günleri ve notları kalır.', + 'tours.recorded.restored': 'Kaydedilmiş iz geri yüklendi', + 'tours.recorded.failed': 'Kaydedilmiş iz geri yüklenemedi.', + 'tours.conversion.analyze': 'Düzenlenebilir sürüm planla', + 'tours.conversion.analyzing': '{count} kontrol noktası üzerinden rota hesaplanıyor…', + 'tours.conversion.analyzing.one': '{count} kontrol noktası üzerinden rota hesaplanıyor…', + 'tours.conversion.failedTrack': 'Bu iz, bir tur planlamak için çok kısa.', + 'tours.conversion.failedRouting': 'Kontrol noktaları üzerinden rota bulunamadı.', + 'tours.conversion.controlPoints': 'Kontrol noktaları', + 'tours.conversion.withinCorridor': 'İze en fazla {m} m uzaklıkta', + 'tours.conversion.importLength': 'İçe aktarım', + 'tours.conversion.suggestionLength': 'Öneri', + 'tours.conversion.maxDeviation': 'En büyük sapma', + 'tours.conversion.legendImport': 'İçe aktarım', + 'tours.conversion.legendSuggestion': 'Öneri', + 'tours.conversion.close': 'Öneri izi yakından takip ediyor. Yeniden rotalandığı için aynı çizgi değildir.', + 'tours.conversion.different': + 'Öneri daha uzun bölümlerde izden ayrılıyor. Onunla plan yapmadan önce haritada kontrol edin.', + 'tours.conversion.adopt': 'Yeni tur olarak planla', + 'tours.conversion.discard': 'Vazgeç', + 'tours.conversion.adopted': 'Yeni tur olarak açıldı. Saklamak için kaydedin; içe aktarım olduğu gibi kalır.', + 'tours.derivedFrom': '{name} temel alınarak planlandı', + 'tours.derivedFromHint': 'Bir içe aktarımdan yeniden rotalandı, bu yüzden kaydedilen izden farklı olabilir.', 'tours.delete.confirmBody': 'Bu tur kalıcı olarak silinsin mi? Rotası, düzenlenebilir ara noktaları ve gün atamaları kaldırılacak. Bu işlem geri alınamaz.', 'tours.delete.bulkConfirmBody': @@ -37,9 +89,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'En yüksek rakım', 'tours.detail.ascent': 'Toplam yükselme', 'tours.detail.descent': 'Toplam alçalma', - 'tours.detail.difficulty': 'Zorluk', 'tours.detail.source': 'Kaynak', - 'tours.detail.gpxReadOnly': 'GPX turları yalnızca görüntülenebilir; henüz planlayıcıda düzenlenemez.', 'tours.detail.navigationHint': 'Navigasyon yürüyüşün başlangıç noktasını açar; rota boyunca yol tarifi vermez.', 'tours.planner.title': 'Tur planla', 'tours.planner.newTour': 'Yeni tur planla', diff --git a/shared/src/i18n/tr/wanderer.ts b/shared/src/i18n/tr/wanderer.ts new file mode 100644 index 000000000..adcea7feb --- /dev/null +++ b/shared/src/i18n/tr/wanderer.ts @@ -0,0 +1,95 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Bağlı', + 'wanderer.notConnected': 'Bağlı değil', + 'wanderer.intro': + 'Parkurlarını tur olarak içe aktarmak için kendi wanderer sunucunuzu bağlayın. Belirteç sunucuda kalır ve bir daha asla gösterilmez.', + 'wanderer.url': 'Sunucu URL’si', + 'wanderer.token': 'API belirteci', + 'wanderer.tokenHint': 'wanderer’da Ayarlar, Hesap, API belirteçleri altında.', + 'wanderer.tokenInvalid': 'Bu bir wanderer API belirteci değil. Belirteçler wanderer_key_ ile başlar.', + 'wanderer.allowInsecureTls': 'Kendinden imzalı sertifikaya izin ver', + 'wanderer.allowInsecureTlsHint': 'Yalnızca kendi ağınızdaki bir sunucu için.', + 'wanderer.test.button': 'Bağlantıyı test et', + 'wanderer.test.ok': 'Bağlandı.', + 'wanderer.test.okCount': 'Bağlandı. {count} parkurunuzu içe aktarabilirsiniz.', + 'wanderer.test.okCount.one': 'Bağlandı. {count} parkurunuzu içe aktarabilirsiniz.', + 'wanderer.disconnect': 'Bağlantıyı kes', + 'wanderer.saved': 'wanderer bağlantısı kaydedildi', + 'wanderer.disconnected': 'wanderer bağlantısı kaldırıldı', + 'wanderer.loadFailed': 'wanderer bağlantısı yüklenemedi', + 'wanderer.privateIp': + 'Bu adres özel bir ağda. TREK bu adrese ulaşamıyorsa yöneticinin dahili ağlara izin vermesi gerekir.', + 'wanderer.error.disabled': 'Yöneticiniz Turlar için wanderer’ı etkinleştirmedi.', + 'wanderer.error.not_connected': 'wanderer henüz bağlı değil.', + 'wanderer.error.invalid_url': 'Bu adres geçerli bir wanderer URL’si değil.', + 'wanderer.error.blocked_address': + 'TREK bu adrese erişemez. Kendi ağınızdaki bir sunucu için yöneticinin ALLOW_INTERNAL_NETWORK=true ayarlaması gerekir.', + 'wanderer.error.unreachable': 'wanderer’a ulaşılamadı. Adresi ve sunucunun çalıştığını kontrol edin.', + 'wanderer.error.tls': 'Sunucunun sertifikası doğrulanamadı. Kendinden imzalıysa aşağıdan izin verin.', + 'wanderer.error.unauthorized': 'wanderer API belirtecini kabul etmedi. İptal edilmiş veya süresi dolmuş olabilir.', + 'wanderer.error.forbidden': 'wanderer bu parkura erişimi reddetti.', + 'wanderer.error.not_found': 'Bu parkur artık wanderer’da yok.', + 'wanderer.error.not_own_trail': 'Yalnızca kendi wanderer parkurlarınız içe aktarılabilir.', + 'wanderer.error.invalid_response': + 'Yanıt wanderer’dan gelmedi. Adresin doğrudan wanderer’ı gösterdiğini kontrol edin.', + 'wanderer.error.too_large': 'Parkur dosyası 10 MB’tan büyük.', + 'wanderer.error.server_error': 'wanderer bir hatayla yanıt verdi. Daha sonra tekrar deneyin.', + 'wanderer.error.offline': 'Çevrimdışısınız. wanderer’da yalnızca çevrimiçiyken arama yapılabilir.', + 'wanderer.error.preview_stale': 'Siz bakarken parkur wanderer’da değişti.', + 'wanderer.error.already_imported': 'Bu parkur zaten seyahatte.', + 'wanderer.error.no_track': 'Bu parkurun içe aktarılacak izi veya rotası yok.', + 'wanderer.error.unknown': 'wanderer ile ilgili bir sorun oluştu.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Parkurlarınız', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'wanderer sunucunuzdan bir parkur içe aktarın', + 'wanderer.browser.backToList': 'Sonuçlara dön', + 'wanderer.browser.searchPlaceholder': 'Parkur ara', + 'wanderer.browser.areaOff': 'Bu harita alanında ara', + 'wanderer.browser.areaOn': 'Bu harita alanında aranıyor', + 'wanderer.browser.areaOnHint': 'Yalnızca haritanın gördüğünüz kısmına uzanan parkurlar', + 'wanderer.browser.areaOffHint': 'Yeniden her yerde ara', + 'wanderer.browser.connectTitle': 'wanderer’ı bağla', + 'wanderer.browser.connect': 'Ayarlardan bağla', + 'wanderer.browser.empty': 'Parkur bulunamadı', + 'wanderer.browser.emptyArea': 'Bu harita alanında parkur yok', + 'wanderer.browser.emptyHint': 'Başka bir kelime deneyin veya haritayı kaydırın.', + 'wanderer.browser.count': '{count} parkur', + 'wanderer.browser.count.one': '{count} parkur', + 'wanderer.browser.more': 'Daha fazla yükle', + 'wanderer.browser.public': 'Herkese açık', + 'wanderer.browser.private': 'Özel', + 'wanderer.browser.publicHint': 'wanderer’da herkese açık: sunucunuzdaki herkes orada görebilir', + 'wanderer.browser.privateHint': 'wanderer’da özel: orada yalnızca siz görebilirsiniz', + 'wanderer.browser.inTrip': 'Bu seyahatte', + 'wanderer.browser.inTripHint': 'Bu seyahate zaten içe aktarıldı', + 'wanderer.browser.areaBadge': 'Harita alanı', + 'wanderer.preview.loading': 'Parkur wanderer’dan okunuyor…', + 'wanderer.preview.measured': 'GPX dosyasından', + 'wanderer.preview.points': 'İz noktaları', + 'wanderer.preview.noElevation': 'Dosyada yükseklik bilgisi yok, bu yüzden çıkış ve iniş bilinmiyor.', + 'wanderer.preview.openExternal': 'wanderer’da aç', + 'wanderer.preview.import': 'Seyahate aktar', + 'wanderer.preview.importNewVersion': 'Yeni sürüm olarak içe aktar', + 'wanderer.preview.openSnapshot': 'Turu aç', + 'wanderer.preview.openSnapshotShort': 'Bu seyahatteki turu aç', + 'wanderer.preview.newHint': + 'İçe aktarma, parkuru bu seyahate ayrı bir tur olarak kopyalar. wanderer’daki sonraki değişiklikler onu etkilemez.', + 'wanderer.preview.unchanged': + 'Bu seyahatte “{name}” adıyla zaten var, {date} tarihinde içe aktarıldı ve o zamandan beri wanderer’da değişmedi.', + 'wanderer.preview.changed': + '{date} tarihinde “{name}” olarak içe aktarıldı. O zamandan beri wanderer’da değişenler: {parts}.', + 'wanderer.preview.editedLocally': 'Bu seyahatteki tur o zamandan beri burada düzenlendi.', + 'wanderer.preview.newVersionHint': + 'Yeniden içe aktarmak yanına yeni bir sürüm ekler. Seyahatteki tur olduğu gibi kalır.', + 'wanderer.preview.change.name': 'ad', + 'wanderer.preview.change.description': 'açıklama', + 'wanderer.preview.change.route': 'rota', + 'wanderer.tourTitle': 'wanderer turu', + 'wanderer.import.done': '“{name}” içe aktarıldı', + 'wanderer.import.stale': 'Parkur wanderer’da değişti. İşte güncel sürüm.', +}; +export default wanderer; diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index eacb0fa27..2d49796e1 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -676,6 +676,17 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Доповнення оновлено', 'admin.addons.toast.error': 'Не вдалося оновити доповнення', 'admin.addons.group.count': 'Увімкнено {enabled} з {total}', + 'admin.addons.subOptions': '{count} параметра', + 'admin.addons.subOptions.one': '{count} параметр', + 'admin.addons.subOptions.few': '{count} параметри', + 'admin.addons.subOptions.many': '{count} параметрів', + 'admin.addons.subOptionsOn': '{count} увімк.', + 'admin.addons.subOptionsOn.one': '{count} увімк.', + 'admin.addons.subOptionsOn.few': '{count} увімк.', + 'admin.addons.subOptionsOn.many': '{count} увімк.', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Користувачі можуть підключити власний екземпляр wanderer та імпортувати його маршрути як тури', 'admin.addons.noAddons': 'Немає доступних доповнень', 'admin.weather.title': 'Дані про погоду', 'admin.weather.badge': 'З 24 березня 2026', diff --git a/shared/src/i18n/uk/index.ts b/shared/src/i18n/uk/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/uk/index.ts +++ b/shared/src/i18n/uk/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts index 4d4455647..78a0c06e0 100644 --- a/shared/src/i18n/uk/tours.ts +++ b/shared/src/i18n/uk/tours.ts @@ -24,8 +24,67 @@ const tours: TranslationStrings = { 'tours.import.success.one': 'Імпортовано {count} тур із GPX', 'tours.import.success.few': 'Імпортовано {count} тури з GPX', 'tours.import.success.many': 'Імпортовано {count} турів із GPX', - 'tours.import.noTrack': 'У цьому файлі GPX не знайдено треку або маршруту', - 'tours.import.error': 'Не вдалося імпортувати файл GPX', + 'tours.import.noTrackIn': 'У файлі {name} не знайдено треку або маршруту', + 'tours.import.errorIn': 'Не вдалося імпортувати {name}', + 'tours.import.button': 'Імпорт GPX', + 'tours.import.hint': 'Імпортувати файли GPX як тури цієї подорожі', + 'tours.import.unsupported': 'Як тури можна імпортувати лише файли GPX.', + 'tours.import.skipped': 'Пропущено {count} треку: вони вже є в цій подорожі', + 'tours.import.skipped.one': 'Пропущено {count} трек: він уже є в цій подорожі', + 'tours.import.skipped.few': 'Пропущено {count} треки: вони вже є в цій подорожі', + 'tours.import.skipped.many': 'Пропущено {count} треків: вони вже є в цій подорожі', + 'tours.import.allSkipped': 'Усе з цього файлу вже є в цій подорожі.', + 'tours.import.drop': 'Перетягніть сюди файли GPX, щоб імпортувати їх', + 'tours.import.dropHint': 'Кожен трек або маршрут у файлі стане туром цієї подорожі.', + 'tours.import.dropNoPermission': 'Ви не можете додавати тури до цієї подорожі.', + 'tours.import.importing': 'Імпорт GPX…', + 'tours.days.label': 'Дні подорожі', + 'tours.days.none': 'Ще не призначено на жоден день подорожі.', + 'tours.days.short': 'Дні {days}', + 'tours.days.short.one': 'День {days}', + 'tours.days.short.few': 'Дні {days}', + 'tours.days.short.many': 'Дні {days}', + 'tours.alreadyOnDay': 'Цей тур уже призначено на цей день.', + 'tours.conversion.title': 'Редагована версія', + 'tours.conversion.intro': + 'Імпорт є записаним треком. Сплануйте редаговану версію: TREK візьме з нього контрольні точки, заново прокладе через них маршрут і покаже, наскільки близько він підходить. Нічого не зміниться, доки ви не оберете.', + 'tours.conversion.replace': 'Замінити імпорт', + 'tours.conversion.replaceHint': + 'Планує цей тур заново на тому самому місці: його дні та нотатки залишаються, а записаний трек зберігається, щоб ви могли до нього повернутися.', + 'tours.conversion.adoptHint': 'Планує новий тур поруч з імпортом, який залишається без змін.', + 'tours.conversion.replacing': + 'Відкрито в редакторі. Збережіть його, щоб замінити імпорт; записаний трек можна буде відновити.', + 'tours.recorded.title': 'Записаний трек', + 'tours.recorded.hint': 'Цей тур сплановано заново поверх імпорту. Трек, з яким його було записано, збережено.', + 'tours.recorded.restore': 'Відновити записаний трек', + 'tours.recorded.confirmTitle': 'Відновити записаний трек?', + 'tours.recorded.confirmBody': + 'Маршрутні точки та прокладену лінію буде видалено, і тур знову показуватиме трек, з яким його було записано. Його дні та нотатки залишаються.', + 'tours.recorded.restored': 'Записаний трек відновлено', + 'tours.recorded.failed': 'Не вдалося відновити записаний трек.', + 'tours.conversion.analyze': 'Спланувати редаговану версію', + 'tours.conversion.analyzing': 'Прокладання маршруту через {count} контрольної точки…', + 'tours.conversion.analyzing.one': 'Прокладання маршруту через {count} контрольну точку…', + 'tours.conversion.analyzing.few': 'Прокладання маршруту через {count} контрольні точки…', + 'tours.conversion.analyzing.many': 'Прокладання маршруту через {count} контрольних точок…', + 'tours.conversion.failedTrack': 'Цей трек закороткий, щоб спланувати за ним тур.', + 'tours.conversion.failedRouting': 'Не вдалося прокласти маршрут через контрольні точки.', + 'tours.conversion.controlPoints': 'Контрольні точки', + 'tours.conversion.withinCorridor': 'У межах {m} м від треку', + 'tours.conversion.importLength': 'Імпорт', + 'tours.conversion.suggestionLength': 'Пропозиція', + 'tours.conversion.maxDeviation': 'Найбільше відхилення', + 'tours.conversion.legendImport': 'Імпорт', + 'tours.conversion.legendSuggestion': 'Пропозиція', + 'tours.conversion.close': + 'Пропозиція близько повторює трек. Маршрут прокладено заново, тому лінія збігається не повністю.', + 'tours.conversion.different': + 'На довгих ділянках пропозиція відходить від треку. Перевірте її на карті, перш ніж планувати за нею.', + 'tours.conversion.adopt': 'Спланувати як новий тур', + 'tours.conversion.discard': 'Відхилити', + 'tours.conversion.adopted': 'Відкрито як новий тур. Збережіть його, щоб не втратити; імпорт залишиться без змін.', + 'tours.derivedFrom': 'Сплановано на основі {name}', + 'tours.derivedFromHint': 'Маршрут заново прокладено за імпортом, тому він може відрізнятися від записаного треку.', 'tours.delete.confirmBody': 'Видалити цей маршрут назавжди? Його шлях, редаговані точки та призначення на дні буде видалено. Цю дію не можна скасувати.', 'tours.delete.bulkConfirmBody': @@ -40,9 +99,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Максимальна висота', 'tours.detail.ascent': 'Набір висоти', 'tours.detail.descent': 'Втрата висоти', - 'tours.detail.difficulty': 'Складність', 'tours.detail.source': 'Джерело', - 'tours.detail.gpxReadOnly': 'Тури GPX доступні лише для перегляду; редагувати їх у планувальнику поки не можна.', 'tours.detail.navigationHint': 'Навігація відкриває початкову точку стежки, але не веде вздовж маршруту.', 'tours.planner.title': 'Спланувати тур', 'tours.planner.newTour': 'Спланувати новий тур', diff --git a/shared/src/i18n/uk/wanderer.ts b/shared/src/i18n/uk/wanderer.ts new file mode 100644 index 000000000..962418ded --- /dev/null +++ b/shared/src/i18n/uk/wanderer.ts @@ -0,0 +1,98 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Підключено', + 'wanderer.notConnected': 'Не підключено', + 'wanderer.intro': + 'Підключіть власний екземпляр wanderer, щоб імпортувати його маршрути як тури. Токен зберігається на сервері й більше ніколи не показується.', + 'wanderer.url': 'URL екземпляра', + 'wanderer.token': 'API-токен', + 'wanderer.tokenHint': 'У wanderer: «Налаштування», «Обліковий запис», «API-токени».', + 'wanderer.tokenInvalid': 'Це не API-токен wanderer. Токен починається з wanderer_key_.', + 'wanderer.allowInsecureTls': 'Дозволити самопідписаний сертифікат', + 'wanderer.allowInsecureTlsHint': 'Лише для екземпляра у вашій власній мережі.', + 'wanderer.test.button': 'Перевірити підключення', + 'wanderer.test.ok': 'Підключено.', + 'wanderer.test.okCount': 'Підключено. Маршрутів для імпорту: {count}.', + 'wanderer.test.okCount.one': 'Підключено. Маршрутів для імпорту: {count}.', + 'wanderer.test.okCount.few': 'Підключено. Маршрутів для імпорту: {count}.', + 'wanderer.test.okCount.many': 'Підключено. Маршрутів для імпорту: {count}.', + 'wanderer.disconnect': 'Відключити', + 'wanderer.saved': 'Підключення до wanderer збережено', + 'wanderer.disconnected': 'Підключення до wanderer видалено', + 'wanderer.loadFailed': 'Не вдалося завантажити підключення до wanderer', + 'wanderer.privateIp': + 'Ця адреса належить до приватної мережі. Якщо TREK не може до неї підключитися, адміністратор має дозволити внутрішні мережі.', + 'wanderer.error.disabled': 'Адміністратор не ввімкнув wanderer для турів.', + 'wanderer.error.not_connected': 'wanderer ще не підключено.', + 'wanderer.error.invalid_url': 'Ця адреса не є дійсним URL wanderer.', + 'wanderer.error.blocked_address': + 'TREK не дозволено звертатися до цієї адреси. Для екземпляра у вашій власній мережі адміністратор має встановити ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': + 'Не вдалося зв’язатися з wanderer. Перевірте адресу та переконайтеся, що екземпляр працює.', + 'wanderer.error.tls': 'Не вдалося перевірити сертифікат екземпляра. Якщо він самопідписаний, дозвольте його нижче.', + 'wanderer.error.unauthorized': 'wanderer не прийняв API-токен. Можливо, його відкликано або термін його дії минув.', + 'wanderer.error.forbidden': 'wanderer відмовив у доступі до цього маршруту.', + 'wanderer.error.not_found': 'Цього маршруту більше немає у wanderer.', + 'wanderer.error.not_own_trail': 'Імпортувати можна лише власні маршрути wanderer.', + 'wanderer.error.invalid_response': + 'Відповідь надійшла не від wanderer. Перевірте, що адреса вказує саме на wanderer.', + 'wanderer.error.too_large': 'Файл маршруту більший за 10 МБ.', + 'wanderer.error.server_error': 'wanderer відповів помилкою. Спробуйте пізніше.', + 'wanderer.error.offline': 'Немає підключення до мережі. Пошук у wanderer доступний лише онлайн.', + 'wanderer.error.preview_stale': 'Маршрут змінився у wanderer, поки ви його переглядали.', + 'wanderer.error.already_imported': 'Цей маршрут уже є в подорожі.', + 'wanderer.error.no_track': 'У цьому маршруті немає треку або лінії маршруту для імпорту.', + 'wanderer.error.unknown': 'Під час роботи з wanderer сталася помилка.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Ваші маршрути', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Імпортувати маршрут із вашого екземпляра wanderer', + 'wanderer.browser.backToList': 'Назад до результатів', + 'wanderer.browser.searchPlaceholder': 'Пошук маршрутів', + 'wanderer.browser.areaOff': 'Шукати в цій області карти', + 'wanderer.browser.areaOn': 'Пошук у цій області карти', + 'wanderer.browser.areaOnHint': 'Лише маршрути, що заходять у видиму частину карти', + 'wanderer.browser.areaOffHint': 'Знову шукати всюди', + 'wanderer.browser.connectTitle': 'Підключити wanderer', + 'wanderer.browser.connect': 'Підключити в налаштуваннях', + 'wanderer.browser.empty': 'Маршрутів не знайдено', + 'wanderer.browser.emptyArea': 'У цій області карти немає маршрутів', + 'wanderer.browser.emptyHint': 'Спробуйте інше слово або посуньте карту.', + 'wanderer.browser.count': '{count} маршруту', + 'wanderer.browser.count.one': '{count} маршрут', + 'wanderer.browser.count.few': '{count} маршрути', + 'wanderer.browser.count.many': '{count} маршрутів', + 'wanderer.browser.more': 'Завантажити ще', + 'wanderer.browser.public': 'Загальнодоступний', + 'wanderer.browser.private': 'Приватний', + 'wanderer.browser.publicHint': 'Загальнодоступний у wanderer: його бачать усі користувачі вашого екземпляра', + 'wanderer.browser.privateHint': 'Приватний у wanderer: його бачите лише ви', + 'wanderer.browser.inTrip': 'У цій подорожі', + 'wanderer.browser.inTripHint': 'Уже імпортовано в цю подорож', + 'wanderer.browser.areaBadge': 'Область карти', + 'wanderer.preview.loading': 'Читання маршруту з wanderer…', + 'wanderer.preview.measured': 'З файлу GPX', + 'wanderer.preview.points': 'Точки треку', + 'wanderer.preview.noElevation': 'У файлі немає даних про висоту, тому набір і скидання висоти невідомі.', + 'wanderer.preview.openExternal': 'Відкрити у wanderer', + 'wanderer.preview.import': 'Імпортувати в подорож', + 'wanderer.preview.importNewVersion': 'Імпортувати як нову версію', + 'wanderer.preview.openSnapshot': 'Відкрити тур', + 'wanderer.preview.openSnapshotShort': 'Відкрити тур у цій подорожі', + 'wanderer.preview.newHint': + 'Під час імпорту маршрут копіюється в цю подорож як окремий тур. Подальші зміни у wanderer його не зачіпають.', + 'wanderer.preview.unchanged': + 'Уже є в цій подорожі як «{name}», імпортовано {date}, і відтоді у wanderer не змінювався.', + 'wanderer.preview.changed': 'Імпортовано {date} як «{name}». Відтоді змінено у wanderer: {parts}.', + 'wanderer.preview.editedLocally': 'Відтоді тур у цій подорожі редагували тут.', + 'wanderer.preview.newVersionHint': 'Повторний імпорт додасть поруч нову версію. Тур у подорожі залишиться без змін.', + 'wanderer.preview.change.name': 'назва', + 'wanderer.preview.change.description': 'опис', + 'wanderer.preview.change.route': 'трек', + 'wanderer.tourTitle': 'Тур wanderer', + 'wanderer.import.done': 'Імпортовано: «{name}»', + 'wanderer.import.stale': 'Маршрут змінився у wanderer. Показано поточну версію.', +}; +export default wanderer; diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 564a21641..c359a4bef 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -718,6 +718,11 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': 'Đã cập nhật tiện ích bổ sung', 'admin.addons.toast.error': 'Không thể cập nhật tiện ích bổ sung', 'admin.addons.group.count': '{enabled} trên {total} đã bật', + 'admin.addons.subOptions': '{count} tùy chọn', + 'admin.addons.subOptionsOn': '{count} đang bật', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': + 'Người dùng có thể kết nối máy chủ wanderer riêng và nhập các cung đường của máy chủ đó thành chuyến tham quan', 'admin.addons.noAddons': 'Không có tiện ích bổ sung nào', 'admin.weather.title': 'Dữ liệu thời tiết', 'admin.weather.badge': 'Kể từ ngày 24 tháng 3 năm 2026', diff --git a/shared/src/i18n/vi/index.ts b/shared/src/i18n/vi/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/vi/index.ts +++ b/shared/src/i18n/vi/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts index 06e35bdcd..f489f4f61 100644 --- a/shared/src/i18n/vi/tours.ts +++ b/shared/src/i18n/vi/tours.ts @@ -21,8 +21,60 @@ const tours: TranslationStrings = { 'tours.caution.tooltip': 'Dữ liệu độ cao hoặc thông số chưa đầy đủ; đã nhập kèm cảnh báo', 'tours.import.caution': 'Đã nhập kèm cảnh báo; dữ liệu độ cao hoặc thông số chưa đầy đủ', 'tours.import.success': 'Đã nhập {count} chuyến tham quan từ GPX', - 'tours.import.noTrack': 'Không tìm thấy vệt hành trình hoặc tuyến đường trong tệp GPX này', - 'tours.import.error': 'Không thể nhập tệp GPX', + 'tours.import.noTrackIn': 'Không tìm thấy vệt hành trình hoặc tuyến đường trong {name}', + 'tours.import.errorIn': 'Không thể nhập {name}', + 'tours.import.button': 'Nhập GPX', + 'tours.import.hint': 'Nhập tệp GPX thành các chuyến tham quan của chuyến đi này', + 'tours.import.unsupported': 'Chỉ có thể nhập tệp GPX thành chuyến tham quan.', + 'tours.import.skipped': '{count} vệt hành trình đã có trong chuyến đi này nên được bỏ qua', + 'tours.import.allSkipped': 'Mọi nội dung trong tệp đó đã có trong chuyến đi này.', + 'tours.import.drop': 'Thả tệp GPX vào đây để nhập', + 'tours.import.dropHint': + 'Mỗi vệt hành trình hoặc tuyến đường trong tệp sẽ trở thành một chuyến tham quan của chuyến đi này.', + 'tours.import.dropNoPermission': 'Bạn không thể thêm chuyến tham quan vào chuyến đi này.', + 'tours.import.importing': 'Đang nhập GPX…', + 'tours.days.label': 'Các ngày của chuyến đi', + 'tours.days.none': 'Chưa được gán vào ngày nào của chuyến đi.', + 'tours.days.short': 'Ngày {days}', + 'tours.alreadyOnDay': 'Chuyến tham quan này đã có trong ngày đó.', + 'tours.conversion.title': 'Phiên bản có thể chỉnh sửa', + 'tours.conversion.intro': + 'Bản nhập là một vệt hành trình đã ghi. Lên kế hoạch một phiên bản có thể chỉnh sửa: TREK lấy các điểm kiểm soát từ đó, định tuyến lại và cho thấy kết quả gần đến mức nào. Không có gì thay đổi cho đến khi bạn chọn.', + 'tours.conversion.replace': 'Thay thế bản nhập', + 'tours.conversion.replaceHint': + 'Lên kế hoạch lại chuyến tham quan này ngay tại chỗ: các ngày và ghi chú được giữ nguyên, vệt hành trình đã ghi cũng được lưu lại để bạn có thể quay về.', + 'tours.conversion.adoptHint': 'Lên kế hoạch một chuyến tham quan mới bên cạnh bản nhập, bản nhập vẫn giữ nguyên.', + 'tours.conversion.replacing': + 'Đã mở trong trình chỉnh sửa. Hãy lưu để thay thế bản nhập; vệt hành trình đã ghi vẫn có thể khôi phục.', + 'tours.recorded.title': 'Vệt hành trình đã ghi', + 'tours.recorded.hint': + 'Chuyến tham quan này đã được lên kế hoạch lại dựa trên một bản nhập. Vệt hành trình đã ghi của nó vẫn được giữ lại.', + 'tours.recorded.restore': 'Khôi phục vệt hành trình đã ghi', + 'tours.recorded.confirmTitle': 'Khôi phục vệt hành trình đã ghi?', + 'tours.recorded.confirmBody': + 'Các điểm trên tuyến và đường đã định tuyến sẽ bị xóa, chuyến tham quan sẽ hiển thị lại vệt hành trình đã ghi. Các ngày và ghi chú được giữ nguyên.', + 'tours.recorded.restored': 'Đã khôi phục vệt hành trình đã ghi', + 'tours.recorded.failed': 'Không thể khôi phục vệt hành trình đã ghi.', + 'tours.conversion.analyze': 'Lên kế hoạch phiên bản có thể chỉnh sửa', + 'tours.conversion.analyzing': 'Đang định tuyến qua {count} điểm kiểm soát…', + 'tours.conversion.failedTrack': 'Vệt hành trình này quá ngắn để lên kế hoạch chuyến tham quan.', + 'tours.conversion.failedRouting': 'Không tìm thấy tuyến đường đi qua các điểm kiểm soát.', + 'tours.conversion.controlPoints': 'Điểm kiểm soát', + 'tours.conversion.withinCorridor': 'Trong phạm vi {m} m quanh vệt hành trình', + 'tours.conversion.importLength': 'Bản nhập', + 'tours.conversion.suggestionLength': 'Đề xuất', + 'tours.conversion.maxDeviation': 'Độ lệch lớn nhất', + 'tours.conversion.legendImport': 'Bản nhập', + 'tours.conversion.legendSuggestion': 'Đề xuất', + 'tours.conversion.close': + 'Đề xuất bám sát vệt hành trình. Vì được định tuyến lại nên đây không phải là cùng một đường.', + 'tours.conversion.different': + 'Đề xuất rời khỏi vệt hành trình trên những đoạn dài. Hãy kiểm tra trên bản đồ trước khi dùng để lên kế hoạch.', + 'tours.conversion.adopt': 'Lên kế hoạch thành chuyến tham quan mới', + 'tours.conversion.discard': 'Bỏ', + 'tours.conversion.adopted': 'Đã mở thành chuyến tham quan mới. Hãy lưu để giữ lại; bản nhập vẫn giữ nguyên.', + 'tours.derivedFrom': 'Lên kế hoạch từ {name}', + 'tours.derivedFromHint': 'Được định tuyến lại từ một bản nhập nên có thể khác với vệt hành trình đã ghi.', 'tours.delete.confirmBody': 'Xóa vĩnh viễn chuyến đi này? Tuyến đường, các điểm trung gian có thể chỉnh sửa và lịch gán ngày sẽ bị xóa. Không thể hoàn tác thao tác này.', 'tours.delete.bulkConfirmBody': @@ -37,9 +89,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': 'Độ cao cao nhất', 'tours.detail.ascent': 'Tổng độ cao leo lên', 'tours.detail.descent': 'Tổng độ cao đi xuống', - 'tours.detail.difficulty': 'Độ khó', 'tours.detail.source': 'Nguồn', - 'tours.detail.gpxReadOnly': 'Chuyến tham quan GPX chỉ có thể xem, chưa thể chỉnh sửa trong công cụ lập kế hoạch.', 'tours.detail.navigationHint': 'Chỉ đường sẽ mở điểm bắt đầu đường mòn, không hướng dẫn đi dọc theo tuyến đường.', 'tours.planner.title': 'Lên kế hoạch tham quan', 'tours.planner.newTour': 'Lên kế hoạch tham quan mới', diff --git a/shared/src/i18n/vi/wanderer.ts b/shared/src/i18n/vi/wanderer.ts new file mode 100644 index 000000000..0524e428e --- /dev/null +++ b/shared/src/i18n/vi/wanderer.ts @@ -0,0 +1,92 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': 'Đã kết nối', + 'wanderer.notConnected': 'Chưa kết nối', + 'wanderer.intro': + 'Kết nối máy chủ wanderer của riêng bạn để nhập các cung đường trên đó thành chuyến tham quan. Token được giữ trên máy chủ và sẽ không bao giờ hiển thị lại.', + 'wanderer.url': 'URL máy chủ', + 'wanderer.token': 'Token API', + 'wanderer.tokenHint': 'Trong wanderer, mục Cài đặt, Tài khoản, Token API.', + 'wanderer.tokenInvalid': 'Đây không phải token API của wanderer. Token bắt đầu bằng wanderer_key_.', + 'wanderer.allowInsecureTls': 'Cho phép chứng chỉ tự ký', + 'wanderer.allowInsecureTlsHint': 'Chỉ dành cho máy chủ trong mạng riêng của bạn.', + 'wanderer.test.button': 'Kiểm tra kết nối', + 'wanderer.test.ok': 'Đã kết nối.', + 'wanderer.test.okCount': 'Đã kết nối. Bạn có thể nhập {count} cung đường của mình.', + 'wanderer.disconnect': 'Ngắt kết nối', + 'wanderer.saved': 'Đã lưu kết nối wanderer', + 'wanderer.disconnected': 'Đã xóa kết nối wanderer', + 'wanderer.loadFailed': 'Không thể tải kết nối wanderer', + 'wanderer.privateIp': + 'Địa chỉ này nằm trong mạng riêng. Nếu TREK không truy cập được, quản trị viên cần cho phép mạng nội bộ.', + 'wanderer.error.disabled': 'Quản trị viên chưa bật wanderer cho Chuyến tham quan.', + 'wanderer.error.not_connected': 'wanderer chưa được kết nối.', + 'wanderer.error.invalid_url': 'Địa chỉ đó không phải là URL wanderer hợp lệ.', + 'wanderer.error.blocked_address': + 'TREK không được phép truy cập địa chỉ đó. Với máy chủ trong mạng riêng của bạn, quản trị viên cần đặt ALLOW_INTERNAL_NETWORK=true.', + 'wanderer.error.unreachable': 'Không thể kết nối tới wanderer. Hãy kiểm tra địa chỉ và đảm bảo máy chủ đang chạy.', + 'wanderer.error.tls': 'Không thể xác minh chứng chỉ của máy chủ. Nếu đó là chứng chỉ tự ký, hãy cho phép ở bên dưới.', + 'wanderer.error.unauthorized': 'wanderer không chấp nhận token API. Token có thể đã bị thu hồi hoặc hết hạn.', + 'wanderer.error.forbidden': 'wanderer từ chối quyền truy cập cung đường này.', + 'wanderer.error.not_found': 'wanderer không còn cung đường này.', + 'wanderer.error.not_own_trail': 'Chỉ có thể nhập các cung đường wanderer của chính bạn.', + 'wanderer.error.invalid_response': + 'Phản hồi không đến từ wanderer. Hãy kiểm tra địa chỉ có trỏ đúng tới wanderer hay không.', + 'wanderer.error.too_large': 'Tệp cung đường lớn hơn 10 MB.', + 'wanderer.error.server_error': 'wanderer trả về lỗi. Vui lòng thử lại sau.', + 'wanderer.error.offline': 'Bạn đang ngoại tuyến. Chỉ có thể tìm kiếm wanderer khi trực tuyến.', + 'wanderer.error.preview_stale': 'Cung đường đã thay đổi trong wanderer trong lúc bạn đang xem.', + 'wanderer.error.already_imported': 'Cung đường này đã có trong chuyến đi.', + 'wanderer.error.no_track': 'Cung đường này không có vệt hành trình hoặc tuyến đường để nhập.', + 'wanderer.error.unknown': 'Đã xảy ra sự cố với wanderer.', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': 'Cung đường của bạn', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': 'Nhập một cung đường từ máy chủ wanderer của bạn', + 'wanderer.browser.backToList': 'Quay lại kết quả', + 'wanderer.browser.searchPlaceholder': 'Tìm cung đường', + 'wanderer.browser.areaOff': 'Tìm trong vùng bản đồ này', + 'wanderer.browser.areaOn': 'Đang tìm trong vùng bản đồ này', + 'wanderer.browser.areaOnHint': 'Chỉ những cung đường đi vào phần bản đồ bạn đang thấy', + 'wanderer.browser.areaOffHint': 'Tìm lại ở mọi nơi', + 'wanderer.browser.connectTitle': 'Kết nối wanderer', + 'wanderer.browser.connect': 'Kết nối trong cài đặt', + 'wanderer.browser.empty': 'Không tìm thấy cung đường nào', + 'wanderer.browser.emptyArea': 'Không có cung đường nào trong vùng bản đồ này', + 'wanderer.browser.emptyHint': 'Hãy thử từ khác hoặc di chuyển bản đồ.', + 'wanderer.browser.count': '{count} cung đường', + 'wanderer.browser.more': 'Tải thêm', + 'wanderer.browser.public': 'Công khai', + 'wanderer.browser.private': 'Riêng tư', + 'wanderer.browser.publicHint': 'Công khai trong wanderer: mọi người trên máy chủ của bạn đều có thể xem ở đó', + 'wanderer.browser.privateHint': 'Riêng tư trong wanderer: chỉ bạn có thể xem ở đó', + 'wanderer.browser.inTrip': 'Trong chuyến đi này', + 'wanderer.browser.inTripHint': 'Đã được nhập vào chuyến đi này', + 'wanderer.browser.areaBadge': 'Vùng bản đồ', + 'wanderer.preview.loading': 'Đang đọc cung đường từ wanderer…', + 'wanderer.preview.measured': 'Từ tệp GPX', + 'wanderer.preview.points': 'Điểm vệt hành trình', + 'wanderer.preview.noElevation': 'Tệp không có dữ liệu độ cao nên không xác định được độ cao leo lên và đi xuống.', + 'wanderer.preview.openExternal': 'Mở trong wanderer', + 'wanderer.preview.import': 'Nhập vào chuyến đi', + 'wanderer.preview.importNewVersion': 'Nhập thành phiên bản mới', + 'wanderer.preview.openSnapshot': 'Mở chuyến tham quan', + 'wanderer.preview.openSnapshotShort': 'Mở chuyến tham quan trong chuyến đi này', + 'wanderer.preview.newHint': + 'Khi nhập, cung đường được sao chép vào chuyến đi này thành một chuyến tham quan riêng. Các thay đổi sau này trong wanderer sẽ không ảnh hưởng đến nó.', + 'wanderer.preview.unchanged': + 'Đã có trong chuyến đi này với tên “{name}”, được nhập vào {date} và chưa thay đổi trong wanderer kể từ đó.', + 'wanderer.preview.changed': 'Đã nhập vào {date} với tên “{name}”. Đã thay đổi trong wanderer kể từ đó: {parts}.', + 'wanderer.preview.editedLocally': 'Chuyến tham quan trong chuyến đi này đã được chỉnh sửa tại đây kể từ đó.', + 'wanderer.preview.newVersionHint': + 'Nhập lại sẽ thêm một phiên bản mới bên cạnh. Chuyến tham quan trong chuyến đi vẫn giữ nguyên.', + 'wanderer.preview.change.name': 'tên', + 'wanderer.preview.change.description': 'mô tả', + 'wanderer.preview.change.route': 'tuyến đường', + 'wanderer.tourTitle': 'Chuyến tham quan wanderer', + 'wanderer.import.done': 'Đã nhập “{name}”', + 'wanderer.import.stale': 'Cung đường đã thay đổi trong wanderer. Đây là phiên bản hiện tại.', +}; +export default wanderer; diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 113d1dbc1..a503f66b0 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -641,6 +641,10 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': '擴充套件已更新', 'admin.addons.toast.error': '更新擴充套件失敗', 'admin.addons.group.count': '{total} 項中已啟用 {enabled} 項', + 'admin.addons.subOptions': '{count} 個選項', + 'admin.addons.subOptionsOn': '已開啟 {count} 個', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': '使用者可以連結自己的 wanderer 執行個體,並將其中的步道匯入為遊程', 'admin.addons.noAddons': '暫無可用擴充套件', 'admin.weather.title': '天氣資料', 'admin.weather.badge': '自 2026 年 3 月 24 日起', diff --git a/shared/src/i18n/zh-TW/index.ts b/shared/src/i18n/zh-TW/index.ts index 0ba02c123..b6ade9a7c 100644 --- a/shared/src/i18n/zh-TW/index.ts +++ b/shared/src/i18n/zh-TW/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts index 83210ffb2..b90874c1f 100644 --- a/shared/src/i18n/zh-TW/tours.ts +++ b/shared/src/i18n/zh-TW/tours.ts @@ -21,8 +21,53 @@ const tours: TranslationStrings = { 'tours.caution.tooltip': '海拔或指標資料不完整,已附警告匯入', 'tours.import.caution': '已附警告匯入,海拔或指標資料不完整', 'tours.import.success': '已從 GPX 匯入 {count} 個遊程', - 'tours.import.noTrack': '這個 GPX 檔案中找不到軌跡或路線', - 'tours.import.error': '無法匯入 GPX 檔案', + 'tours.import.noTrackIn': '{name} 中找不到軌跡或路線', + 'tours.import.errorIn': '無法匯入 {name}', + 'tours.import.button': '匯入 GPX', + 'tours.import.hint': '將 GPX 檔案匯入為這趟旅行的遊程', + 'tours.import.unsupported': '只有 GPX 檔案可以匯入為遊程。', + 'tours.import.skipped': '有 {count} 條軌跡已在這趟旅行中,已略過', + 'tours.import.allSkipped': '這個檔案中的所有內容都已在這趟旅行中。', + 'tours.import.drop': '拖放 GPX 檔案以匯入', + 'tours.import.dropHint': '檔案中的每條軌跡或路線都會成為這趟旅行的一個遊程。', + 'tours.import.dropNoPermission': '你無法在這趟旅行中新增遊程。', + 'tours.import.importing': '正在匯入 GPX…', + 'tours.days.label': '旅行日期', + 'tours.days.none': '尚未安排到旅行的任何一天。', + 'tours.days.short': '第 {days} 天', + 'tours.alreadyOnDay': '這個遊程已在那一天。', + 'tours.conversion.title': '可編輯版本', + 'tours.conversion.intro': + '匯入的內容是一條錄製的軌跡。規劃一個可編輯版本:TREK 會從中取出控制點,重新規劃路線,並顯示結果有多接近。在你做出選擇之前,什麼都不會改變。', + 'tours.conversion.replace': '取代匯入', + 'tours.conversion.replaceHint': '在原處重新規劃此遊程:它的日程和備註會保留,錄製的軌跡也會保存,方便你日後還原。', + 'tours.conversion.adoptHint': '在匯入旁規劃一個新遊程,匯入的內容會保持原樣。', + 'tours.conversion.replacing': '已在編輯器中開啟。儲存後即可取代匯入;錄製的軌跡仍可還原。', + 'tours.recorded.title': '錄製的軌跡', + 'tours.recorded.hint': '此遊程是在匯入的基礎上重新規劃的。錄製時的軌跡已保存。', + 'tours.recorded.restore': '還原錄製的軌跡', + 'tours.recorded.confirmTitle': '要還原錄製的軌跡嗎?', + 'tours.recorded.confirmBody': '路線點和規劃的線條將被移除,遊程會重新顯示錄製時的軌跡。它的日程和備註會保留。', + 'tours.recorded.restored': '已還原錄製的軌跡', + 'tours.recorded.failed': '無法還原錄製的軌跡。', + 'tours.conversion.analyze': '規劃可編輯版本', + 'tours.conversion.analyzing': '正在規劃經過 {count} 個控制點的路線…', + 'tours.conversion.failedTrack': '這條軌跡太短,無法據此規劃遊程。', + 'tours.conversion.failedRouting': '找不到經過這些控制點的路線。', + 'tours.conversion.controlPoints': '控制點', + 'tours.conversion.withinCorridor': '距軌跡 {m} 公尺以內', + 'tours.conversion.importLength': '匯入', + 'tours.conversion.suggestionLength': '建議', + 'tours.conversion.maxDeviation': '最大偏差', + 'tours.conversion.legendImport': '匯入', + 'tours.conversion.legendSuggestion': '建議', + 'tours.conversion.close': '建議路線與軌跡大致吻合。由於是重新規劃的,並非完全相同的線條。', + 'tours.conversion.different': '建議路線在較長的路段偏離軌跡。用它規劃之前,請先在地圖上確認。', + 'tours.conversion.adopt': '規劃為新遊程', + 'tours.conversion.discard': '捨棄', + 'tours.conversion.adopted': '已作為新遊程開啟。儲存後即可保留;匯入的內容會保持原樣。', + 'tours.derivedFrom': '根據 {name} 規劃', + 'tours.derivedFromHint': '根據匯入內容重新規劃,可能與記錄的軌跡不同。', 'tours.delete.confirmBody': '要永久刪除此行程嗎?其路線、可編輯的途經點和日期指派都會一併移除。此操作無法復原。', 'tours.delete.bulkConfirmBody': '所選內容至少包含一個行程。所有選取的地點都會永久刪除且無法復原;行程路線、可編輯的途經點和日期指派也會一併移除。', @@ -35,9 +80,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': '最高海拔', 'tours.detail.ascent': '累計爬升', 'tours.detail.descent': '累計下降', - 'tours.detail.difficulty': '難度', 'tours.detail.source': '來源', - 'tours.detail.gpxReadOnly': 'GPX 遊程僅供檢視,目前還無法在規劃工具中編輯。', 'tours.detail.navigationHint': '導航會開啟登山口或步道入口,不會沿著遊程路線提供導航。', 'tours.planner.title': '規劃遊程', 'tours.planner.newTour': '規劃新遊程', diff --git a/shared/src/i18n/zh-TW/wanderer.ts b/shared/src/i18n/zh-TW/wanderer.ts new file mode 100644 index 000000000..0b4e77bd0 --- /dev/null +++ b/shared/src/i18n/zh-TW/wanderer.ts @@ -0,0 +1,87 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': '已連線', + 'wanderer.notConnected': '未連線', + 'wanderer.intro': + '連結你自己的 wanderer 執行個體,即可將其中的步道匯入為遊程。權杖會保存在伺服器上,之後不會再次顯示。', + 'wanderer.url': '執行個體 URL', + 'wanderer.token': 'API 權杖', + 'wanderer.tokenHint': '位於 wanderer 的「設定」、「帳戶」、「API 權杖」。', + 'wanderer.tokenInvalid': '這不是 wanderer 的 API 權杖。權杖以 wanderer_key_ 開頭。', + 'wanderer.allowInsecureTls': '允許自我簽署憑證', + 'wanderer.allowInsecureTlsHint': '僅適用於你自己網路中的執行個體。', + 'wanderer.test.button': '測試連線', + 'wanderer.test.ok': '已連線。', + 'wanderer.test.okCount': '已連線。你可以匯入 {count} 條步道。', + 'wanderer.disconnect': '中斷連線', + 'wanderer.saved': '已儲存 wanderer 連線', + 'wanderer.disconnected': '已移除 wanderer 連線', + 'wanderer.loadFailed': '無法載入 wanderer 連線', + 'wanderer.privateIp': '這個位址位於私人網路中。如果 TREK 無法連上,需要管理員允許內部網路。', + 'wanderer.error.disabled': '管理員尚未為遊程啟用 wanderer。', + 'wanderer.error.not_connected': '尚未連結 wanderer。', + 'wanderer.error.invalid_url': '這個位址不是有效的 wanderer URL。', + 'wanderer.error.blocked_address': + 'TREK 不得連上這個位址。若是你自己網路中的執行個體,管理員需要設定 ALLOW_INTERNAL_NETWORK=true。', + 'wanderer.error.unreachable': '無法連上 wanderer。請檢查位址,並確認執行個體正在運作。', + 'wanderer.error.tls': '無法驗證執行個體的憑證。若是自我簽署憑證,請在下方允許。', + 'wanderer.error.unauthorized': 'wanderer 不接受這個 API 權杖。它可能已被撤銷或已過期。', + 'wanderer.error.forbidden': 'wanderer 拒絕存取這條步道。', + 'wanderer.error.not_found': 'wanderer 中已沒有這條步道。', + 'wanderer.error.not_own_trail': '只能匯入你自己的 wanderer 步道。', + 'wanderer.error.invalid_response': '這個回應並非來自 wanderer。請確認位址指向 wanderer 本身。', + 'wanderer.error.too_large': '步道檔案超過 10 MB。', + 'wanderer.error.server_error': 'wanderer 回應了錯誤。請稍後再試。', + 'wanderer.error.offline': '你目前離線。只能在連線時搜尋 wanderer。', + 'wanderer.error.preview_stale': '你檢視期間,這條步道已在 wanderer 中變更。', + 'wanderer.error.already_imported': '這條步道已在旅行中。', + 'wanderer.error.no_track': '這條步道沒有可匯入的軌跡或路線。', + 'wanderer.error.unknown': 'wanderer 發生問題。', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': '你的步道', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': '從你的 wanderer 執行個體匯入步道', + 'wanderer.browser.backToList': '返回結果', + 'wanderer.browser.searchPlaceholder': '搜尋步道', + 'wanderer.browser.areaOff': '搜尋這個地圖範圍', + 'wanderer.browser.areaOn': '正在搜尋這個地圖範圍', + 'wanderer.browser.areaOnHint': '只顯示延伸到目前可見地圖範圍的步道', + 'wanderer.browser.areaOffHint': '重新搜尋所有範圍', + 'wanderer.browser.connectTitle': '連結 wanderer', + 'wanderer.browser.connect': '在設定中連結', + 'wanderer.browser.empty': '找不到步道', + 'wanderer.browser.emptyArea': '這個地圖範圍內沒有步道', + 'wanderer.browser.emptyHint': '換個關鍵字試試,或移動地圖。', + 'wanderer.browser.count': '{count} 條步道', + 'wanderer.browser.more': '載入更多', + 'wanderer.browser.public': '公開', + 'wanderer.browser.private': '私人', + 'wanderer.browser.publicHint': '在 wanderer 中公開:你執行個體上的所有人都能在那裡看到', + 'wanderer.browser.privateHint': '在 wanderer 中私人:只有你能在那裡看到', + 'wanderer.browser.inTrip': '已在這趟旅行中', + 'wanderer.browser.inTripHint': '已匯入這趟旅行', + 'wanderer.browser.areaBadge': '地圖範圍', + 'wanderer.preview.loading': '正在從 wanderer 讀取步道…', + 'wanderer.preview.measured': '來自 GPX 檔案', + 'wanderer.preview.points': '軌跡點', + 'wanderer.preview.noElevation': '這個檔案沒有海拔資料,因此累計爬升和下降未知。', + 'wanderer.preview.openExternal': '在 wanderer 中開啟', + 'wanderer.preview.import': '匯入旅行', + 'wanderer.preview.importNewVersion': '匯入為新版本', + 'wanderer.preview.openSnapshot': '開啟遊程', + 'wanderer.preview.openSnapshotShort': '開啟這趟旅行中的遊程', + 'wanderer.preview.newHint': '匯入會將步道複製到這趟旅行,成為一個獨立的遊程。之後在 wanderer 中的變更不會影響它。', + 'wanderer.preview.unchanged': '已於 {date} 以「{name}」匯入這趟旅行,此後在 wanderer 中沒有變更。', + 'wanderer.preview.changed': '已於 {date} 以「{name}」匯入。此後在 wanderer 中有變更:{parts}。', + 'wanderer.preview.editedLocally': '這趟旅行中的遊程此後已在這裡編輯過。', + 'wanderer.preview.newVersionHint': '再次匯入會在旁邊新增一個新版本。旅行中的遊程會保持原樣。', + 'wanderer.preview.change.name': '名稱', + 'wanderer.preview.change.description': '描述', + 'wanderer.preview.change.route': '路線', + 'wanderer.tourTitle': 'wanderer 遊程', + 'wanderer.import.done': '已匯入「{name}」', + 'wanderer.import.stale': '這條步道已在 wanderer 中變更。以下是目前的版本。', +}; +export default wanderer; diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 51f717f1b..ab764635c 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -591,6 +591,10 @@ const admin: TranslationStrings = { 'admin.addons.toast.updated': '扩展已更新', 'admin.addons.toast.error': '更新扩展失败', 'admin.addons.group.count': '{total} 项中已启用 {enabled} 项', + 'admin.addons.subOptions': '{count} 个选项', + 'admin.addons.subOptionsOn': '已开启 {count} 个', + 'admin.toursWanderer.title': 'wanderer', + 'admin.toursWanderer.subtitle': '用户可以连接自己的 wanderer 实例,并将其中的步道作为游览路线导入', 'admin.addons.noAddons': '暂无可用扩展', 'admin.weather.title': '天气数据', 'admin.weather.badge': '自 2026 年 3 月 24 日起', diff --git a/shared/src/i18n/zh/index.ts b/shared/src/i18n/zh/index.ts index e2faa8e69..3fc4b520f 100644 --- a/shared/src/i18n/zh/index.ts +++ b/shared/src/i18n/zh/index.ts @@ -52,6 +52,7 @@ import trip from './trip'; import trips from './trips'; import undo from './undo'; import vacay from './vacay'; +import wanderer from './wanderer'; const locale = { ...common, @@ -60,6 +61,7 @@ const locale = { ...dashboard, ...roadtrip, ...tours, + ...wanderer, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts index d37de81aa..f97a194c4 100644 --- a/shared/src/i18n/zh/tours.ts +++ b/shared/src/i18n/zh/tours.ts @@ -21,8 +21,54 @@ const tours: TranslationStrings = { 'tours.caution.tooltip': '海拔或指标数据不完整,已带警告导入', 'tours.import.caution': '已带警告导入,海拔或指标数据不完整', 'tours.import.success': '已从 GPX 导入 {count} 条游览路线', - 'tours.import.noTrack': '该 GPX 文件中未找到轨迹或路线', - 'tours.import.error': '无法导入 GPX 文件', + 'tours.import.noTrackIn': '{name} 中未找到轨迹或路线', + 'tours.import.errorIn': '无法导入 {name}', + 'tours.import.button': '导入 GPX', + 'tours.import.hint': '将 GPX 文件导入为此旅行的游览路线', + 'tours.import.unsupported': '只有 GPX 文件可以导入为游览路线。', + 'tours.import.skipped': '有 {count} 条轨迹已在此旅行中,已跳过', + 'tours.import.allSkipped': '该文件中的所有内容都已在此旅行中。', + 'tours.import.drop': '拖放 GPX 文件以导入', + 'tours.import.dropHint': '文件中的每条轨迹或路线都会成为此旅行的一条游览路线。', + 'tours.import.dropNoPermission': '你无法向此旅行添加游览路线。', + 'tours.import.importing': '正在导入 GPX…', + 'tours.days.label': '旅行日期', + 'tours.days.none': '尚未安排到旅行的任何一天。', + 'tours.days.short': '第 {days} 天', + 'tours.alreadyOnDay': '此游览路线已在那一天。', + 'tours.conversion.title': '可编辑版本', + 'tours.conversion.intro': + '导入的内容是一条录制的轨迹。规划一个可编辑版本:TREK 会从中提取控制点,重新规划路线,并显示结果有多接近。在你做出选择之前,什么都不会改变。', + 'tours.conversion.replace': '替换导入', + 'tours.conversion.replaceHint': + '在原处重新规划此游览路线:它的日程和备注保持不变,录制的轨迹也会保留,方便你随时恢复。', + 'tours.conversion.adoptHint': '在导入旁边规划一条新游览路线,导入的内容保持不变。', + 'tours.conversion.replacing': '已在编辑器中打开。保存后即可替换导入;录制的轨迹仍可恢复。', + 'tours.recorded.title': '录制的轨迹', + 'tours.recorded.hint': '此游览路线是在导入的基础上重新规划的。录制时的轨迹已保留。', + 'tours.recorded.restore': '恢复录制的轨迹', + 'tours.recorded.confirmTitle': '要恢复录制的轨迹吗?', + 'tours.recorded.confirmBody': '路线点和规划的线路将被删除,游览路线会重新显示录制时的轨迹。它的日程和备注保持不变。', + 'tours.recorded.restored': '已恢复录制的轨迹', + 'tours.recorded.failed': '无法恢复录制的轨迹。', + 'tours.conversion.analyze': '规划可编辑版本', + 'tours.conversion.analyzing': '正在规划经过 {count} 个控制点的路线…', + 'tours.conversion.failedTrack': '此轨迹太短,无法据此规划游览路线。', + 'tours.conversion.failedRouting': '未找到经过这些控制点的路线。', + 'tours.conversion.controlPoints': '控制点', + 'tours.conversion.withinCorridor': '距轨迹 {m} 米以内', + 'tours.conversion.importLength': '导入', + 'tours.conversion.suggestionLength': '建议', + 'tours.conversion.maxDeviation': '最大偏差', + 'tours.conversion.legendImport': '导入', + 'tours.conversion.legendSuggestion': '建议', + 'tours.conversion.close': '建议路线与轨迹基本吻合。由于是重新规划的,并非完全相同的线路。', + 'tours.conversion.different': '建议路线在较长路段上偏离轨迹。用它规划之前,请先在地图上检查。', + 'tours.conversion.adopt': '规划为新游览路线', + 'tours.conversion.discard': '放弃', + 'tours.conversion.adopted': '已作为新游览路线打开。保存后即可保留;导入的内容保持不变。', + 'tours.derivedFrom': '根据 {name} 规划', + 'tours.derivedFromHint': '根据导入内容重新规划,可能与录制的轨迹不同。', 'tours.delete.confirmBody': '要永久删除此行程吗?其路线、可编辑的途经点和日期安排都将被删除。此操作无法撤销。', 'tours.delete.bulkConfirmBody': '所选内容至少包含一个行程。所有选中的地点都将被永久删除且无法恢复;行程路线、可编辑的途经点和日期安排也会一并删除。', @@ -35,9 +81,7 @@ const tours: TranslationStrings = { 'tours.detail.maxAltitude': '最高海拔', 'tours.detail.ascent': '累计爬升', 'tours.detail.descent': '累计下降', - 'tours.detail.difficulty': '难度', 'tours.detail.source': '来源', - 'tours.detail.gpxReadOnly': 'GPX 游览路线仅供查看,暂时无法在规划器中编辑。', 'tours.detail.navigationHint': '导航打开的是步道起点,不会沿游览路线提供导航。', 'tours.planner.title': '规划游览路线', 'tours.planner.newTour': '规划新游览路线', diff --git a/shared/src/i18n/zh/wanderer.ts b/shared/src/i18n/zh/wanderer.ts new file mode 100644 index 000000000..d422e5140 --- /dev/null +++ b/shared/src/i18n/zh/wanderer.ts @@ -0,0 +1,88 @@ +import type { TranslationStrings } from '../types'; + +const wanderer: TranslationStrings = { + 'wanderer.title': 'wanderer', + 'wanderer.connected': '已连接', + 'wanderer.notConnected': '未连接', + 'wanderer.intro': + '连接你自己的 wanderer 实例,即可将其中的步道导入为游览路线。令牌保存在服务器上,之后不会再次显示。', + 'wanderer.url': '实例 URL', + 'wanderer.token': 'API 令牌', + 'wanderer.tokenHint': '在 wanderer 的“设置”、“账户”、“API 令牌”中获取。', + 'wanderer.tokenInvalid': '这不是 wanderer 的 API 令牌。令牌以 wanderer_key_ 开头。', + 'wanderer.allowInsecureTls': '允许自签名证书', + 'wanderer.allowInsecureTlsHint': '仅适用于你自己网络中的实例。', + 'wanderer.test.button': '测试连接', + 'wanderer.test.ok': '已连接。', + 'wanderer.test.okCount': '已连接。你可以导入 {count} 条步道。', + 'wanderer.disconnect': '断开连接', + 'wanderer.saved': '已保存 wanderer 连接', + 'wanderer.disconnected': '已移除 wanderer 连接', + 'wanderer.loadFailed': '无法加载 wanderer 连接', + 'wanderer.privateIp': '此地址位于私有网络中。如果 TREK 无法访问,需要管理员允许内部网络。', + 'wanderer.error.disabled': '管理员尚未为游览启用 wanderer。', + 'wanderer.error.not_connected': '尚未连接 wanderer。', + 'wanderer.error.invalid_url': '该地址不是有效的 wanderer URL。', + 'wanderer.error.blocked_address': + 'TREK 不能访问该地址。对于你自己网络中的实例,管理员需要设置 ALLOW_INTERNAL_NETWORK=true。', + 'wanderer.error.unreachable': '无法访问 wanderer。请检查地址以及实例是否正在运行。', + 'wanderer.error.tls': '无法验证该实例的证书。如果是自签名证书,请在下方允许。', + 'wanderer.error.unauthorized': 'wanderer 未接受该 API 令牌。它可能已被撤销或已过期。', + 'wanderer.error.forbidden': 'wanderer 拒绝访问此步道。', + 'wanderer.error.not_found': 'wanderer 中已不存在此步道。', + 'wanderer.error.not_own_trail': '只能导入你自己的 wanderer 步道。', + 'wanderer.error.invalid_response': '该响应并非来自 wanderer。请检查地址是否指向 wanderer 本身。', + 'wanderer.error.too_large': '步道文件超过 10 MB。', + 'wanderer.error.server_error': 'wanderer 返回了错误。请稍后再试。', + 'wanderer.error.offline': '你处于离线状态。只能在联网时搜索 wanderer。', + 'wanderer.error.preview_stale': '你查看期间,此步道已在 wanderer 中被修改。', + 'wanderer.error.already_imported': '此步道已在旅行中。', + 'wanderer.error.no_track': '此步道没有可导入的轨迹或路线。', + 'wanderer.error.unknown': 'wanderer 出现问题。', + 'wanderer.browser.title': 'wanderer', + 'wanderer.browser.sub': '你的步道', + 'wanderer.browser.open': 'wanderer', + 'wanderer.browser.openHint': '从你的 wanderer 实例导入步道', + 'wanderer.browser.backToList': '返回结果', + 'wanderer.browser.searchPlaceholder': '搜索步道', + 'wanderer.browser.areaOff': '搜索此地图区域', + 'wanderer.browser.areaOn': '正在搜索此地图区域', + 'wanderer.browser.areaOnHint': '仅显示延伸到当前可见地图区域的步道', + 'wanderer.browser.areaOffHint': '重新搜索全部范围', + 'wanderer.browser.connectTitle': '连接 wanderer', + 'wanderer.browser.connect': '在设置中连接', + 'wanderer.browser.empty': '未找到步道', + 'wanderer.browser.emptyArea': '此地图区域内没有步道', + 'wanderer.browser.emptyHint': '换个关键词试试,或移动地图。', + 'wanderer.browser.count': '{count} 条步道', + 'wanderer.browser.more': '加载更多', + 'wanderer.browser.public': '公开', + 'wanderer.browser.private': '私密', + 'wanderer.browser.publicHint': '在 wanderer 中公开:你实例上的所有人都能在那里看到', + 'wanderer.browser.privateHint': '在 wanderer 中私密:只有你能在那里看到', + 'wanderer.browser.inTrip': '已在此旅行中', + 'wanderer.browser.inTripHint': '已导入到此旅行', + 'wanderer.browser.areaBadge': '地图区域', + 'wanderer.preview.loading': '正在从 wanderer 读取步道…', + 'wanderer.preview.measured': '来自 GPX 文件', + 'wanderer.preview.points': '轨迹点', + 'wanderer.preview.noElevation': '该文件没有海拔数据,因此累计爬升和下降未知。', + 'wanderer.preview.openExternal': '在 wanderer 中打开', + 'wanderer.preview.import': '导入到旅行', + 'wanderer.preview.importNewVersion': '作为新版本导入', + 'wanderer.preview.openSnapshot': '打开游览路线', + 'wanderer.preview.openSnapshotShort': '打开此旅行中的游览路线', + 'wanderer.preview.newHint': + '导入会将步道复制到此旅行中,作为一条独立的游览路线。之后在 wanderer 中的修改不会影响它。', + 'wanderer.preview.unchanged': '已于 {date} 以“{name}”导入此旅行,此后在 wanderer 中未作修改。', + 'wanderer.preview.changed': '已于 {date} 以“{name}”导入。此后在 wanderer 中有修改:{parts}。', + 'wanderer.preview.editedLocally': '此旅行中的游览路线此后在这里被编辑过。', + 'wanderer.preview.newVersionHint': '再次导入会在旁边添加一个新版本。旅行中的游览路线保持不变。', + 'wanderer.preview.change.name': '名称', + 'wanderer.preview.change.description': '描述', + 'wanderer.preview.change.route': '路线', + 'wanderer.tourTitle': 'wanderer 游览路线', + 'wanderer.import.done': '已导入“{name}”', + 'wanderer.import.stale': '此步道已在 wanderer 中被修改。这是当前版本。', +}; +export default wanderer; diff --git a/shared/src/index.ts b/shared/src/index.ts index fb6412ed8..88241e969 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -50,6 +50,7 @@ export * from './roadtrip/roadtrip.schema'; export * from './place/track-colors'; export * from './tours/tours.schema'; export * from './tours/tours.metrics'; +export * from './wanderer/wanderer.schema'; export * from './geo/haversine'; export * from './collection/collection.schema'; export * from './collection/collection-file.schema'; diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index 1a975066d..5562f6dd0 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -77,6 +77,8 @@ export const tourSchema = z.object({ match_confidence: z.number().min(0).max(1).nullable(), // NULL for a GPX import: the track carries no difficulty. A drawn tour always has one. max_hiking_difficulty: tourMaxHikingDifficultySchema.nullable(), + /** The import (a GPX file, a wanderer snapshot) this tour was planned from, while it is still in the trip. */ + derived_from_place_id: z.number().int().positive().nullable().optional(), }); export type Tour = z.infer; @@ -102,6 +104,8 @@ export const tourListItemSchema = tourSchema.extend({ caution: z.boolean(), /** Read-model provenance signal: planner-authored tours persist routing controls; GPX imports do not. */ has_waypoints: z.boolean().optional(), + /** An import planned over in place still keeps the track it was recorded with, and can go back to it. */ + has_recorded_track: z.boolean().optional(), }); export type TourListItem = z.infer; @@ -177,6 +181,11 @@ export const tourCreateRequestSchema = z break_additional_minutes: plannedTourDurationMinutesSchema.optional(), description: z.string().trim().max(2000).nullable().optional(), website: tourWebsiteSchema.nullable().optional(), + /** + * Creating only: the import of this trip the tour was planned from. It is + * where the tour came from, so an edit later neither sets nor changes it. + */ + derived_from_place_id: z.number().int().positive().nullable().optional(), }) .superRefine((value, ctx) => { if ( diff --git a/shared/src/wanderer/wanderer.schema.ts b/shared/src/wanderer/wanderer.schema.ts new file mode 100644 index 000000000..8f88c77a5 --- /dev/null +++ b/shared/src/wanderer/wanderer.schema.ts @@ -0,0 +1,237 @@ +import { idSchema } from '../common/primitives.schema'; +import { tourListItemSchema } from '../tours/tours.schema'; + +import { z } from 'zod'; + +/** + * wanderer integration contracts (RS-03 to RS-06, MF-02). + * + * wanderer (github.com/open-wanderer/wanderer) is a self-hosted trail + * database. A user connects their own instance with a personal API token; the + * token reaches that user's whole account, so it is kept like a password: it + * is stored encrypted, never sent back, and every call to the instance goes + * from the server. + * + * What a trip gets out of it is a snapshot: a trail is copied into the trip as + * an independent tour, with its original GPX and a note of where it came from. + * Nothing is synchronised. A later import of the same trail is compared with + * the snapshot and the user decides what happens; TREK never replaces or + * deletes a tour on its own. + * + * Verified against wanderer v0.21.0 (2026-09-28), its source and a running + * instance: auth is `Authorization: Bearer wanderer_key_...` on `/api/v1/*`, + * a token without that prefix is silently treated as anonymous, and a revoked + * one answers HTTP 500. + */ + +/** Every token wanderer issues starts with this; anything else is read as no token at all. */ +export const WANDERER_TOKEN_PREFIX = 'wanderer_key_'; + +/** Placeholder returned instead of the stored token. Never the token itself. */ +export const WANDERER_TOKEN_MASK = '••••••••'; + +/** Why a call to the instance did not work, as a code the client translates. */ +export const wandererErrorCodeSchema = z.enum([ + /** The admin has not allowed wanderer under Tours. */ + 'disabled', + 'not_connected', + 'invalid_url', + /** The SSRF guard refused the address: a private network the admin has not allowed, or worse. */ + 'blocked_address', + 'unreachable', + 'tls', + 'unauthorized', + 'forbidden', + 'not_found', + /** The trail is someone else's: only the connected account's own trails are imported. */ + 'not_own_trail', + 'invalid_response', + 'too_large', + 'server_error', +]); +export type WandererErrorCode = z.infer; + +// ── Per-user connection ────────────────────────────────────────────────────── + +const instanceUrlSchema = z.string().trim().min(1).max(2048); + +/** A typed token, or the mask / nothing for "keep the stored one". */ +const tokenInputSchema = z + .string() + .trim() + .max(512) + .refine((value) => value === '' || value === WANDERER_TOKEN_MASK || value.startsWith(WANDERER_TOKEN_PREFIX), { + message: 'not a wanderer API token', + }); + +export const wandererSettingsRequestSchema = z.object({ + /** Instance origin, e.g. https://wanderer.example.com; TREK appends /api/v1 itself. */ + url: instanceUrlSchema, + /** Omitted, blank or the mask keeps the stored token. */ + token: tokenInputSchema.optional(), + /** Allow a self-signed certificate, which instances on a home network often use. */ + allowInsecureTls: z.boolean().optional().default(false), +}); +export type WandererSettingsRequest = z.infer; + +/** A test of what is typed in the form, before it is saved; blank fields fall back to the stored ones. */ +export const wandererTestRequestSchema = z.object({ + url: instanceUrlSchema.optional(), + token: tokenInputSchema.optional(), + allowInsecureTls: z.boolean().optional(), +}); +export type WandererTestRequest = z.infer; + +export const wandererConnectionSchema = z.object({ + url: z.string(), + /** The mask when a token is stored, empty when none is. */ + tokenMasked: z.string(), + allowInsecureTls: z.boolean(), + connected: z.boolean(), + /** When the connection last answered a test, and how. */ + lastCheckedAt: z.string().nullable(), + lastCheckOk: z.boolean().nullable(), + lastCheckError: wandererErrorCodeSchema.nullable(), +}); +export type WandererConnection = z.infer; + +export const wandererSaveResponseSchema = z.object({ + success: z.literal(true), + /** The address resolves into a private network: saved, but worth a word. */ + warning: z.literal('private_ip').optional(), +}); +export type WandererSaveResponse = z.infer; + +export const wandererTestResponseSchema = z.object({ + ok: z.boolean(), + code: wandererErrorCodeSchema.optional(), + /** How many trails the token can see, when the instance said. */ + trailCount: z.number().int().nonnegative().nullable().optional(), +}); +export type WandererTestResponse = z.infer; + +// ── Trails ─────────────────────────────────────────────────────────────────── + +const latitude = z.number().finite().min(-90).max(90); +const longitude = z.number().finite().min(-180).max(180); + +/** The part of the map being looked at. `west` greater than `east` crosses the antimeridian. */ +export const wandererBboxSchema = z + .object({ south: latitude, west: longitude, north: latitude, east: longitude }) + .refine((box) => box.south <= box.north, { message: 'south must not lie north of north' }); +export type WandererBbox = z.infer; + +export const WANDERER_SEARCH_PAGE_SIZE = 20; +/** Deep pages of a search are a scroll nobody makes; the cap keeps a loop from walking an archive. */ +export const WANDERER_SEARCH_MAX_PAGE = 25; + +export const wandererSearchRequestSchema = z.object({ + q: z.string().trim().max(200).optional().default(''), + page: z.number().int().min(1).max(WANDERER_SEARCH_MAX_PAGE).optional().default(1), + /** Only trails whose extent overlaps this box. */ + bbox: wandererBboxSchema.optional(), +}); +export type WandererSearchRequest = z.input; + +export const wandererDifficultySchema = z.enum(['easy', 'moderate', 'difficult']); +export type WandererDifficulty = z.infer; + +/** + * wanderer's three levels on the one scale tours use, the SAC hiking scale: an + * easy trail is a T1 walk, a moderate one a T2 mountain hike, a difficult one + * a T3. wanderer says no more than that, so nothing above T3 is claimed. + */ +export function wandererDifficultyToSac(difficulty: WandererDifficulty | null | undefined): 1 | 2 | 3 | null { + if (difficulty === 'easy') return 1; + if (difficulty === 'moderate') return 2; + if (difficulty === 'difficult') return 3; + return null; +} + +/** + * A trail as the search lists it: the facts the instance indexes, its line + * simplified for a preview, and whether this trip already holds a snapshot of it. + */ +export const wandererTrailSummarySchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + location: z.string().nullable(), + distanceM: z.number().nullable(), + durationS: z.number().nullable(), + elevationGainM: z.number().nullable(), + elevationLossM: z.number().nullable(), + difficulty: wandererDifficultySchema.nullable(), + public: z.boolean(), + author: z.string().nullable(), + /** `[lat, lng]` pairs of the instance's own simplified line; empty when it has none. */ + line: z.array(z.tuple([latitude, longitude])), + /** The trail on the instance, for the user to open there. */ + externalUrl: z.string(), + /** The tours of this trip that are snapshots of this trail, newest first. */ + importedAs: z.array(idSchema), +}); +export type WandererTrailSummary = z.infer; + +export const wandererSearchResponseSchema = z.object({ + trails: z.array(wandererTrailSummarySchema), + total: z.number().int().nonnegative(), + page: z.number().int().positive(), + pageSize: z.number().int().positive(), +}); +export type WandererSearchResponse = z.infer; + +/** How a trail stands to the snapshot this trip already holds of it. */ +export const wandererSnapshotStateSchema = z.enum([ + /** Not in this trip yet. */ + 'new', + /** In this trip, and the instance holds the same content. */ + 'unchanged', + /** In this trip, and the instance holds something else now. */ + 'changed', +]); +export type WandererSnapshotState = z.infer; + +/** + * Everything the import step shows before anything is written: the trail as + * the full GPX describes it, and how it compares to a snapshot already here. + */ +export const wandererImportPreviewSchema = z.object({ + trail: wandererTrailSummarySchema, + /** Measured from the full GPX, not from the index. */ + distanceKm: z.number().nonnegative(), + elevationGainM: z.number().nullable(), + elevationLossM: z.number().nullable(), + hasElevation: z.boolean(), + points: z.number().int().nonnegative(), + /** `[lat, lng]` of the full line, thinned for the map. */ + line: z.array(z.tuple([latitude, longitude])), + /** Fingerprint of what would be imported; the import refuses when the instance moved on since. */ + contentHash: z.string(), + state: wandererSnapshotStateSchema, + /** The newest snapshot of this trail in the trip, when there is one. */ + snapshot: z + .object({ + placeId: idSchema, + name: z.string(), + importedAt: z.string(), + /** The tour was changed here after it was imported. */ + editedLocally: z.boolean(), + }) + .nullable(), + /** What differs between the snapshot and the instance, when it does. */ + changes: z.array(z.enum(['name', 'description', 'route'])), +}); +export type WandererImportPreview = z.infer; + +export const wandererImportRequestSchema = z.object({ + trailId: z.string().trim().min(1).max(64), + /** The preview's fingerprint: what the user saw is what gets imported, or nothing. */ + contentHash: z.string().trim().min(16).max(128), +}); +export type WandererImportRequest = z.infer; + +export const wandererImportResponseSchema = z.object({ + tour: tourListItemSchema, +}); +export type WandererImportResponse = z.infer;