Compare commits

...

12 Commits

Author SHA1 Message Date
Maurice 0cde099c7f style(assistant): format the blind-call hint 2026-11-03 22:39:24 +01:00
Maurice e798eb712e fix(assistant): run a blind call, load its group, and say why when its arguments fail
Refusing every call to a tool of a group not loaded in the turn also stopped calls whose arguments were fine. The call now runs as before; its group is loaded at once, so the next step offers the schema, and when the arguments fail the MCP check the error tells the model the schema is there now and to send the call again.
2026-11-03 22:09:54 +01:00
Maurice 0dae908e05 fix(assistant): send back a call to a tool whose group the turn has not loaded
The model could call a tool it knew from an earlier turn without loading its group in this one. TREK ran it, but the model had never seen its schema in the turn, so numbers, arrays and objects came as strings and required arguments were missing, and the MCP check refused it with -32602 (mostly the Tours tools).

Such a call no longer runs: its group is loaded for the rest of the turn and the call is answered with a tool error asking for it again with the schema the next step now offers.

Closes #2967
2026-11-03 21:48:37 +01:00
Maurice 01c7bc9de2 fix(tours): plural toast, repo route and test updates for the waypoint import
The places toast is a plural group in every language, the points import goes through tourRepo instead of src/api, the hook and dialog keep their complexity, and the tests that checked the old confirm call or the GPX left-out count follow the new behaviour.
2026-11-03 20:42:14 +01:00
Maurice 2e683f8609 fix(tours): keep the waypoints of a GPX/KML file that also holds a track
With the Tours addon on, a file picked or dropped on the places column goes
into the Tours import as soon as it holds one line, and that import reads
lines only. A Sygic or Trip-o-matic export (a route plus its stops) came in
as one tour and every place was dropped without a word.

The preview now counts a GPX file's waypoints as it already counted a KML
file's pins, and the dialog offers to add those points as places of the
trip, on by default. Confirming runs the Tours import for the lines and the
places import (points only) for the same files. A file whose tracks the
trip already holds can still bring its points.

Closes #2969
2026-11-03 20:19:43 +01:00
Maurice f8e51cb83d fix(pdf): hand the trip PDF to Safari from the iOS Home Screen app
An iPhone or iPad running TREK from the Home Screen ignores print(), in
the page and in an iframe alike, so "Save as PDF" in the preview did
nothing there. Mobile Safari prints the same preview fine.

In the installed iOS app the preview now shows "Open in Safari" in
place of the dead button, with a line saying why. The link opens the
trip with ?export=pdf, and both shells open their export dialog or
sheet for it, so the PDF is one tap away once Safari is up. The
parameter is dropped once read and survives the login redirect.
Everywhere else the preview prints as before.

The preview overlay moves out of TripPDF.tsx into pdfPreview.ts, which
TripPDF had no room left for under lint:size, and its backdrop and
stacking now use the --overlay and --z-modal tokens.
2026-11-03 09:14:55 +01:00
Maurice eedfb08597 Managed mode: list the phone OIDC card and drop the map model from the default settings pair
- MAdminOidcCard is markup over the settings section's state, so it is
  listed under mobileOnly with that reason.
- The default user settings views take their presets from
  useDefaultUserSettings now and import only a type from mapSettingsModel,
  so that pair names the map settings views alone.
2026-11-03 08:44:45 +01:00
Maurice 0aee0a019e Managed mode: lower the test any baseline the journey helper brought down 2026-11-03 08:44:45 +01:00
Maurice 91bc1944dc Managed mode: keep the admin settings and addons ratchets green
- The phone's OIDC card moves into MAdminOidcCard, so the settings section
  is back within its complexity limit after the managed-mode conditions.
- enabledPhotoProviderIds reads the enabled ids straight from the
  repository and has a test of its own, which brings the addons coverage
  back to its threshold.
2026-11-03 08:44:45 +01:00
Maurice f99b40cd81 Managed mode: keep the journey page suite within its size baseline
- The owner sign-in and the photo provider the suite runs with move into
  tests/helpers/journeyDetail.ts, so the page test is back under its line
  baseline, which drops to 4028.
- Drop help/registry.base.ts, an unused copy of the help registry whose key
  builders the i18n key check could not resolve.
2026-11-03 08:44:45 +01:00
Maurice 59e68d81b2 Managed mode: satisfy the strict, loop and test ratchets
- enabledPhotoProviderIds skips rows without an id instead of widening the type.
- The listed events read each addon once, in parallel, not in a loop.
- The narrowing test reuses the suite's service and swaps the addon answers.
- PlaceContribPanel keeps its complexity by naming the standalone check.
2026-11-03 08:44:45 +01:00
Maurice 86513fda0d Managed mode: hand place search, routing, plugins and docs to the operator
On a managed install the operator runs place search and routing, the plugin
runtime, the integrations and the documentation, so the admin and the users
no longer see switches and guides for them:

- The place search and routing card, the transit provider and the search log
  are gone from the admin settings (desktop and mobile). places_provider is a
  managed-locked setting, and the transit provider, search log and wanderer
  routes refuse writes there. Share place details stays the admin's, as a card
  of its own, hidden when TREK_PLACES_CONTRIBUTE switches it off.
- File types and trip reminders move to the right column, so the columns stay
  balanced.
- Plugins disappear from the admin and the user settings, wanderer from Tours.
- The Help & Docs page and its links are not offered. The help center stays and
  leaves out guides about what the operator runs (keys, OIDC, backups, storage,
  releases, plugins, document sync) and about integrations that are off.
- Mapbox GL is offered only with the operator's token, Amap tiles only where
  the operator set Amap up.
- The mobile admin no longer shows OIDC and the WebAuthn relying party fields,
  matching the desktop tab.

For every install:

- Notification matrices list only events the install can raise: no plugin row
  without the plugin runtime, no Vacay, collection, photo, Synology, wanderer or
  packing rows while those are off. On a managed install the release and
  replica alerts are left out.
- The journey entry editor offers External photos only where a photo provider
  is switched on, as the phone already did.
- app-config is asked again after a failed answer. A page opened while the
  server restarted kept every default for the session, which on a managed
  install brought back the operator's settings.
2026-11-03 08:44:45 +01:00
141 changed files with 1802 additions and 356 deletions
+1 -1
View File
@@ -34,7 +34,7 @@
"react-hooks/exhaustive-deps": 46
},
"tests": {
"@typescript-eslint/no-explicit-any": 490,
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1915,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
+3 -5
View File
@@ -742,11 +742,8 @@
{
"feature": "Settings / Map: mapSettingsModel",
"hooks": ["src/components/Settings/mapSettingsModel.ts"],
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx", "src/components/Settings/MapSettingsTab.tsx"],
"mobile": [
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
"src/mobile/screens/settings/MSettingsMap.tsx"
]
"desktop": ["src/components/Settings/MapSettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsMap.tsx"]
},
{
"feature": "Settings / Map: useMapSettingsForm",
@@ -983,6 +980,7 @@
}
],
"mobileOnly": {
"src/mobile/screens/admin/MAdminOidcCard.tsx": "Markup only; the OIDC card of MAdminSettingsSection, with the form state from useAdmin and the save from its useAdminSettingsActions handed in through props.",
"src/mobile/screens/admin/MAdminResetMfaSheet.tsx": "Markup only; the 2FA reset confirm that MAdminSheets opens, with the reset handed in through props from its useAdminUserActions.",
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
"src/mobile/screens/admin/MStorageReclaimCard.tsx": "Markup only; renders the reclaim view and handlers that MAdminStoragePanel takes from useStoragePanel, the twin of StorageReclaimSection.",
+1 -1
View File
@@ -34,7 +34,7 @@
"components/Map/locationMarkerMapbox.ts": 2,
"components/Map/nightPauseMarker.ts": 4,
"components/Map/ratingBadge.ts": 1,
"components/PDF/TripPDF.tsx": 8,
"components/PDF/TripPDF.tsx": 7,
"components/PDF/tripMapImage.ts": 1,
"components/Packing/PackingListPanelBagCard.tsx": 1,
"components/Packing/PackingListPanelCategoryGroup.tsx": 3,
+1 -2
View File
@@ -8,7 +8,6 @@
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2218,
"src/components/PDF/TripPDF.tsx": 1039,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2929,
@@ -18,7 +17,7 @@
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
"src/pages/JourneyDetailPage.test.tsx": 4030,
"src/pages/JourneyDetailPage.test.tsx": 4028,
"src/pages/LoginPage.tsx": 1348,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/atlas/useAtlas.ts": 1210,
+1 -1
View File
@@ -71,7 +71,7 @@
"components/Map/poiCategories.ts": 7,
"components/Map/ratingBadge.ts": 2,
"components/Map/reservationsMapbox.ts": 1,
"components/PDF/TripPDF.tsx": 53,
"components/PDF/TripPDF.tsx": 51,
"components/Packing/PackingListPanelBagModal.tsx": 2,
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
"components/Packing/PackingListPanelHeader.tsx": 5,
+22
View File
@@ -436,6 +436,28 @@ describe('App — on-mount effects', () => {
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
})
it('FE-COMP-APP-018c: app-config is asked again after a failed answer, so managed is not lost', async () => {
// A page opened while the server restarts got a 502 here and kept every
// default for the session, which brought back the operator's settings.
let calls = 0
server.use(
http.get('/api/auth/app-config', () => {
calls += 1
return calls === 1 ? new HttpResponse(null, { status: 502 }) : HttpResponse.json({ managed: true })
})
)
const setManaged = vi.fn()
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setManaged,
})
renderApp('/')
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true), { timeout: 4000 })
expect(calls).toBe(2)
})
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
+25 -3
View File
@@ -323,7 +323,21 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
// Asked again until it answers: a page opened while the server restarts got a
// 502 here and kept the defaults for the whole session, which on a managed
// install put back every setting the operator owns.
let cancelled = false
let retryTimer: ReturnType<typeof setTimeout> | undefined
type AppConfigAnswer = Awaited<ReturnType<typeof authApi.getAppConfig>>
const fetchAppConfig = (attempt: number): Promise<AppConfigAnswer> => authApi.getAppConfig().catch((err: unknown) => {
if (cancelled) throw err
return new Promise<AppConfigAnswer>((resolve, reject) => {
retryTimer = setTimeout(() => {
fetchAppConfig(attempt + 1).then(resolve, reject)
}, Math.min(30_000, 1_000 * 2 ** attempt))
})
})
fetchAppConfig(0).then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
@@ -347,6 +361,10 @@ export default function App() {
// Last, since a new release reloads the page from here.
await reconcileAppVersion(config?.version)
}).catch(() => {})
return () => {
cancelled = true
clearTimeout(retryTimer)
}
}, [])
const { settings } = useSettingsStore()
@@ -402,6 +420,10 @@ export default function App() {
// interceptor's covers all of /public/. A route that is public to one and not
// to the other is exactly the seam that puts a 401 back.
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
// The Help & Docs page is the wiki, written for somebody who runs TREK
// themselves: keys, backups, updates, the server behind it. A managed install
// does not offer it; the in-app help center stays.
const managed = useAuthStore(s => s.managed)
return (
<TranslationProvider>
@@ -456,7 +478,7 @@ export default function App() {
path="/help"
element={
<ProtectedRoute>
<HelpPage />
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
</ProtectedRoute>
}
/>
@@ -464,7 +486,7 @@ export default function App() {
path="/help/:slug"
element={
<ProtectedRoute>
<HelpPage />
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
</ProtectedRoute>
}
/>
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { ToastContainer } from '../shared/Toast';
@@ -679,6 +680,16 @@ describe('AddonManager', () => {
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
});
it('FE-ADMIN-ADDON-042b: a managed install has no wanderer switch under Tours', async () => {
seedStore(useAuthStore, { managed: true });
server.use(addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]));
render(<AddonManager />);
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
await unfoldShelves();
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
});
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 })]),
+13 -8
View File
@@ -73,6 +73,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
toursWanderer,
wandererOffered,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
@@ -157,14 +158,18 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
// "N options" bar the way Collab's and Documents' are.
if (addon.id === 'tours' && addon.enabled) {
return [
<AddonSubRow
key="wanderer"
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>,
...(wandererOffered
? [
<AddonSubRow
key="wanderer"
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>,
]
: []),
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
key={key}
@@ -12,7 +12,7 @@ import type { DistanceUnit, WeekStart } from '../../types'
import { weekStartOptions } from '../../utils/calendarWeek'
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider } from '../Map/glProviders'
import { MAP_PRESETS, type MapProvider } from '../Settings/mapSettingsModel'
import type { MapProvider } from '../Settings/mapSettingsModel'
import RoutingInstanceFields from './RoutingInstanceFields'
import { useDefaultUserSettings, type Defaults } from './useDefaultUserSettings'
@@ -69,7 +69,7 @@ function errorText(err: unknown, fallback: string): string {
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t, locale } = useTranslation()
const {
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
defaults, loaded, managed, presets, canMapbox, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
@@ -217,7 +217,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
value={mapTileUrl}
onChange={pickTilePreset}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
options={presets.map(p => ({ value: p.url, label: p.name }))}
/>
<input
type="text"
@@ -283,7 +283,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
onChange={saveMapProvider}
options={[
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
...(canMapbox ? [{ value: 'mapbox-gl' as const, label: t('admin.defaultSettings.providerMapbox') }] : []),
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
]}
/>
@@ -1,4 +1,4 @@
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-011
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-013
import userEvent from '@testing-library/user-event';
import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared';
import { http, HttpResponse } from 'msw';
@@ -201,4 +201,23 @@ describe('PlaceContribPanel', () => {
vi.useRealTimers();
}
});
it('FE-ADMIN-CONTRIB-012: standing alone, a card the operator switched off is not shown', async () => {
const asked = vi.fn();
server.use(
http.get('/api/admin/places-contribute', () => {
asked();
return HttpResponse.json({ ...UNDECIDED, eligible: false, ineligibleReason: 'env_off', showPrompt: false });
})
);
const { container } = render(<PlaceContribPanel hideWhenSwitchedOff />);
await waitFor(() => expect(asked).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
it('FE-ADMIN-CONTRIB-013: standing alone, any other reason is still explained', async () => {
serve({ ...UNDECIDED, eligible: false, ineligibleReason: 'not_production', showPrompt: false });
render(<PlaceContribPanel hideWhenSwitchedOff />);
expect(await screen.findByText('admin.contribute.ineligible.not_production')).toBeInTheDocument();
});
});
@@ -61,7 +61,15 @@ function Figures({ state, t }: { state: PlaceContribState; t: TranslationFn }) {
* way to take everything back. Turning it on for the first time opens the same
* prompt an admin gets on the first visit, so an agreement always comes from it.
*/
export default function PlaceContribPanel() {
/**
* Standing alone (managed settings), a card that can only say "not available"
* because the operator switched sharing off is a dead end.
*/
function hiddenAlone(state: PlaceContribState, alone: boolean): boolean {
return alone && state.ineligibleReason === 'env_off';
}
export default function PlaceContribPanel({ hideWhenSwitchedOff = false }: { hideWhenSwitchedOff?: boolean } = {}) {
const { t } = useTranslation();
const toast = useToast();
const contrib = usePlaceContrib();
@@ -70,6 +78,7 @@ export default function PlaceContribPanel() {
const [confirm, setConfirm] = useState<'off' | 'withdraw' | null>(null);
if (!state) return null;
if (hiddenAlone(state, hideWhenSwitchedOff)) return null;
const status = contribStatus(state);
const agreed = state.consent.decision === 'agreed' && state.consent.termsVersion === state.termsVersion;
// The prompt opens by itself once per visit while the server asks for it.
@@ -7,6 +7,7 @@ import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../.
import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
import { useAuthStore } from '../../../store/authStore';
import { useToast } from '../../shared/Toast';
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
import { aiAddonsOf, isAiAddon } from './aiAddons';
@@ -36,6 +37,9 @@ export function useAddonManager() {
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
// wanderer sends a user's searches to a server of their choosing, which a
// managed install's terms do not cover; the operator decides, not the admin.
const wandererOffered = !useAuthStore((s) => s.managed);
const [toursHikingMap, setToursHikingMap] = useState(false);
const [toursCycleMap, setToursCycleMap] = useState(false);
const [toursSkiRouting, setToursSkiRouting] = useState(false);
@@ -160,6 +164,7 @@ export function useAddonManager() {
providerOptions,
documentProviderOptions,
toursWanderer,
wandererOffered,
toggleToursWanderer,
mapSwitches,
};
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import type { DistanceUnit, Place, WeekStart } from '../../types';
import { normalizeTileUrl } from '../../utils/tileUrl';
import {
@@ -11,7 +12,13 @@ import {
normalizeStyleForProvider,
styleSettingKey,
} from '../Map/glProviders';
import { type MapProvider, normalizeProvider, styleForProvider } from '../Settings/mapSettingsModel';
import {
type MapProvider,
mapboxOffered,
mapPresetsFor,
normalizeProvider,
styleForProvider,
} from '../Settings/mapSettingsModel';
import { useToast } from '../shared/Toast';
import type { RoutingDefaults } from './RoutingInstanceFields';
@@ -53,6 +60,9 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
const [loaded, setLoaded] = useState(false);
const [mapTileUrl, setMapTileUrl] = useState('');
const managed = useAuthStore((s) => s.managed);
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
// The operator's token reaches the admin's own settings the same way it reaches everybody's.
const operatorMapboxToken = useSettingsStore((s) => s.settings.mapbox_access_token);
const [mapboxToken, setMapboxToken] = useState('');
const [cartoKey, setCartoKey] = useState('');
const [mapboxStyle, setMapboxStyle] = useState('');
@@ -153,6 +163,8 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
defaults,
loaded,
managed,
presets: mapPresetsFor(managed, hasAmapKey),
canMapbox: mapboxOffered(managed, operatorMapboxToken),
isSet,
save,
reset,
+7 -4
View File
@@ -8,6 +8,7 @@ import type { HelpGuide } from '../../help/types'
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
@@ -27,6 +28,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
const openLightbox = useHelpStore(s => s.openLightbox)
const closeHelp = useHelpStore(s => s.closeHelp)
const docIndex = useHelpStore(s => s.docIndex)
// No Help & Docs page on a managed install (App.tsx), so no link into it.
const docs = useAuthStore(s => s.managed) ? undefined : guide.docs
const openGuide = useHelpStore(s => s.openGuide)
const title = t(guideKey(guide.id, 'title'))
const screen = getHelpContext(guide.context)
@@ -180,9 +183,9 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
)}
{/* Go there / docs / related */}
{(guide.link || guide.docs || related.length > 0) && (
{(guide.link || docs || related.length > 0) && (
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(guide.link || guide.docs) && (
{(guide.link || docs) && (
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
<HelpBadge tone="neutral" icon={Link2} uppercase className="self-start">{t('help.center.docsSection')}</HelpBadge>
{guide.link && (
@@ -195,8 +198,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
<ArrowUpRight className="w-4 h-4" />
</Link>
)}
{guide.docs && (
<HelpDocLink to={docsRoute(guide.docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, guide.docs.slug)} />
{docs && (
<HelpDocLink to={docsRoute(docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, docs.slug)} />
)}
</div>
)}
+4 -1
View File
@@ -8,6 +8,7 @@ import type { HelpContext, HelpDocLink as HelpDocTarget, HelpGuide } from '../..
import { helpIcon } from './helpIcons'
import HelpBadge from './HelpBadge'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
import HelpSizeChip from './HelpSizeChip'
import { useScreenRoute } from './useScreenRoute'
@@ -19,6 +20,8 @@ import { useScreenRoute } from './useScreenRoute'
export default function HelpHome({ context }: { context: HelpContext | null }): React.ReactElement {
const { t } = useTranslation()
const docIndex = useHelpStore(s => s.docIndex)
// No Help & Docs page on a managed install (App.tsx), so no links into it.
const managed = useAuthStore(s => s.managed)
const closeHelp = useHelpStore(s => s.closeHelp)
const screenRoute = useScreenRoute(context?.route)
@@ -35,7 +38,7 @@ export default function HelpHome({ context }: { context: HelpContext | null }):
const guides = guidesFor(context)
const bullets = Array.from({ length: context.bullets }, (_, i) => t(ctxBulletKey(context.id, i + 1)))
const children = childHelpContexts(context.id)
const docs = onePerPage(context.docs)
const docs = managed ? [] : onePerPage(context.docs)
const trail = helpContextTrail(context).slice(0, -1)
const Icon = helpIcon(context.icon)
@@ -6,6 +6,7 @@ import { allHelpGuides, guideKey, guideStepKey, guideTipKey, ctxKey } from '../.
import type { HelpGuide } from '../../help/types'
import { GuideCard, SectionLabel } from './HelpHome'
import HelpDocLink from './HelpDocLink'
import { useAuthStore } from '../../store/authStore'
/** Guides shown for a query, at most. The docs list is capped by the server. */
const MAX_GUIDES = 8
@@ -62,6 +63,8 @@ export default function HelpSearchResults(): React.ReactElement {
const { t } = useTranslation()
const query = useHelpStore(s => s.query)
const status = useHelpStore(s => s.searchStatus)
// No Help & Docs page on a managed install (App.tsx): guides only.
const managed = useAuthStore(s => s.managed)
const docHits = useHelpStore(s => s.docHits)
const q = query.trim()
@@ -75,7 +78,7 @@ export default function HelpSearchResults(): React.ReactElement {
.map(x => x.g)
}, [q, t])
const nothing = guides.length === 0 && status === 'done' && docHits.length === 0
const nothing = guides.length === 0 && (managed || (status === 'done' && docHits.length === 0))
return (
<div className="trek-help-view flex flex-col gap-8">
@@ -93,6 +96,7 @@ export default function HelpSearchResults(): React.ReactElement {
</section>
)}
{!managed && (
<section className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<SectionLabel>{t('help.center.searchDocs')}</SectionLabel>
@@ -113,6 +117,7 @@ export default function HelpSearchResults(): React.ReactElement {
</ul>
)}
</section>
)}
{nothing && (
<div className="flex flex-col items-center text-center gap-2 py-10">
@@ -8,6 +8,7 @@ import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpe
import { seedStore } from '../../../tests/helpers/store'
import { buildSettings } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { server } from '../../../tests/helpers/msw/server'
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore'
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue'
@@ -102,6 +103,8 @@ beforeEach(() => {
// The time field reads time_format, so pin it: without this the cases that
// assert a 24h string depend on whichever test ran before them (#2067).
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) })
// A photo provider switched on for the install, so the External photos source is offered.
seedStore(useAddonStore, { addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true } as never] })
window.__addToast = toastSpy
Object.defineProperty(URL, 'createObjectURL', {
configurable: true, writable: true, value: vi.fn(() => 'blob:preview'),
@@ -430,6 +433,14 @@ describe('EntryEditor', () => {
expect(screen.getByText('Roma Termini')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-018b: offers no External photos source where no photo provider is on', () => {
seedStore(useAddonStore, { addons: [] })
mountEditor(buildEntry({ id: 10 }))
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Upload photos/ })).toBeInTheDocument()
})
it('FE-JRN-EDITOR-019: reports that no external provider is connected', async () => {
server.use(http.get('/api/addons', () => HttpResponse.json({})))
const user = userEvent.setup()
@@ -17,6 +17,7 @@ import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, D
import { AddRowButton, EditorField, INPUT } from '../shared/dialogParts'
import { Tooltip } from '../shared/Tooltip'
import { useJourneyEntryForm, type PendingProviderGroup } from './useJourneyEntryForm'
import { useAddonStore } from '../../store/addonStore'
/** A chip of the mood and weather rows, as in the collection dialogs' category row. */
const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold transition-colors'
@@ -139,6 +140,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
onDone: () => void
}) {
const { t } = useTranslation()
const photoProvidersOn = useAddonStore(s => s.isEnabled('memories'))
const {
title, setTitle, story, setStory, entryDate, setEntryDate, entryTime, setEntryTime,
locationName,
@@ -273,14 +275,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
<Camera size={14} />
</button>
</Tooltip>
<button type="button"
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
aria-pressed={photoTab === 'external'}
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
>
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
</button>
{/* Only where the install has a photo provider (Immich, Synology) switched
on, as on the phone: a source that can only say "nothing connected"
is not worth its width, and the other two share the row instead. */}
{photoProvidersOn && (
<button type="button"
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
disabled={saving}
aria-pressed={photoTab === 'external'}
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
>
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
</button>
)}
</div>
{/* The gallery picker, directly below the buttons. Safari collapses
@@ -312,7 +319,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
</div>
</div>
)}
{photoTab === 'external' && (
{photoTab === 'external' && photoProvidersOn && (
<div className={`${PHOTO_PANEL} flex flex-col overflow-hidden`} style={{ height: 'min(56vh, 520px)' }}>
<div className="flex items-center justify-between gap-2 border-b border-edge-faint px-3 py-2">
<div className="min-w-0">
@@ -87,6 +87,16 @@ describe('Navbar', () => {
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-009b: a managed install keeps the help center but has no Help & Docs entry', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser({ username: 'testuser' }), isAuthenticated: true, managed: true });
render(<Navbar />);
expect(document.querySelector('[data-help-button]')).not.toBeNull();
await user.click(screen.getByText('testuser'));
expect(screen.queryByText('Help')).not.toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-010: regular user does not see Admin option', async () => {
const user = userEvent.setup();
render(<Navbar />);
+3 -1
View File
@@ -35,7 +35,7 @@ interface Addon {
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { user, logout, isPrerelease, appVersion, managed } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
@@ -326,6 +326,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
{t('nav.settings')}
</Link>
{!managed && (
<Link to="/help" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
@@ -333,6 +334,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
<BookOpen className="w-4 h-4" />
{t('nav.help')}
</Link>
)}
{user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
+3 -94
View File
@@ -8,7 +8,8 @@ import type { Trip, Day, Place, Category, AssignmentsMap, DayNote, DistanceUnit,
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { hidesOnMiddleDay, getTransportForDay, getMergedItems, getSpanPhase, getDisplayTimeForDay } from '../../utils/dayMerge'
import { safeHexColor } from '../../utils/safeColor'
import { pdfSwitchOn, rememberPdfSwitch } from '../../utils/pdfSwitch'
import { pdfSwitchOn } from '../../utils/pdfSwitch'
import { openPdfPreview, PAGE_BREAK_KEY, TRANSPORT_NOTES_KEY } from './pdfPreview'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { formatMoney, formatMoneySum, formatClockTime, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
import { useSettingsStore } from '../../store/settingsStore'
@@ -23,28 +24,6 @@ import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/fligh
import { onlyMyPlan, planAssignments } from './pdfScope'
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
/**
* Every day starts a new page by default. On a trip of short days that prints
* one sheet per handful of lines, so the preview offers the flowing layout the
* in-app view already uses, and remembers which one was picked (#1292).
*/
const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day'
/**
* The notes of a flight, train or rental (#1571): printed by default, like the
* notes of a place, and a switch in the preview leaves them out for a reader who
* wants the plan without the fine print.
*/
const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes'
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
rebuilt here rather than rendered — as static markup it would carry no
behaviour. Same geometry and the same tokens, so it reads as the same control. */
const TOGGLE_TRACK = 'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;'
const TOGGLE_KNOB = 'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);'
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)')
const knobOffset = (on: boolean) => (on ? '22px' : '2px')
function renderLucideIcon(icon:LucideIcon, props = {}) {
return renderIconMarkup(
createElement(icon, props)
@@ -891,80 +870,10 @@ ${pluginSectionsHtml}
markdown.dispose()
if (signal?.aborted) return
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
const overlay = document.createElement('div')
overlay.id = 'pdf-preview-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
header.innerHTML = `
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
<div style="display:flex;align-items:center;gap:8px">
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
<button type="button" id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
// No script runs inside the document (print is parent-initiated), so withhold
// allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(card)
document.body.appendChild(overlay)
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn')
if (closeBtn) closeBtn.onclick = () => overlay.remove()
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn')
if (printBtn) printBtn.onclick = () => { iframe.contentWindow?.print() }
// Both choices differ by one class on <body>, so switching is instant in the
// preview and there is no need to re-fetch photos or rebuild the document.
wirePreviewSwitch(header, 'pdf-daybreak-toggle', on => {
rememberPdfSwitch(PAGE_BREAK_KEY, on)
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on)
})
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', on => {
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on)
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on)
})
openPdfPreview({ html, title: trip?.title || tr('pdf.travelPlan'), tripId: trip?.id, tr, breaksPerDay, showTransportNotes, hasTransportNotes })
}
function bodyClassAttr(breaksPerDay: boolean, showTransportNotes: boolean): string {
const classes = [!breaksPerDay && 'pdf-flow', !showTransportNotes && 'pdf-no-transport-notes'].filter(Boolean)
return classes.length ? ` class="${classes.join(' ')}"` : ''
}
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
function previewSwitch(id: string, label: string, on: boolean): string {
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escHtml(label)}</label>
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
<span style="${TOGGLE_KNOB} left:${knobOffset(on)}"></span>
</button>`
}
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
const button = header.querySelector<HTMLButtonElement>(`#${id}`)
if (!button) return
button.onclick = () => {
const on = button.getAttribute('aria-checked') !== 'true'
button.setAttribute('aria-checked', String(on))
button.style.background = trackColour(on)
const knob = button.firstElementChild as HTMLElement | null
if (knob) knob.style.left = knobOffset(on)
onChange(on)
}
}
@@ -0,0 +1,90 @@
/**
* FE-PDFPREVIEW-001 to FE-PDFPREVIEW-005: the trip PDF's preview and the control
* that saves it. An iPhone or iPad ignores print() in the Home Screen app, so
* there the preview hands the export to Safari instead of offering a button that
* does nothing.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PAGE_BREAK_KEY, openPdfPreview, type PdfPreviewOptions } from './pdfPreview';
const tr = (key: string) => key;
function open(overrides: Partial<PdfPreviewOptions> = {}) {
openPdfPreview({
html: '<html><body>plan</body></html>',
title: 'Lisbon',
tripId: 9,
tr,
breaksPerDay: true,
showTransportNotes: true,
hasTransportNotes: false,
...overrides,
});
}
function standalone(value: boolean) {
Object.defineProperty(navigator, 'standalone', { configurable: true, get: () => value });
}
afterEach(() => {
document.getElementById('pdf-preview-overlay')?.remove();
delete (navigator as unknown as Record<string, unknown>).standalone;
localStorage.clear();
});
describe('openPdfPreview', () => {
it('FE-PDFPREVIEW-001: a browser gets the print button, and it prints the frame', () => {
open();
expect(document.getElementById('pdf-safari-link')).toBeNull();
expect(document.getElementById('pdf-safari-hint')).toBeNull();
const iframe = document.querySelector<HTMLIFrameElement>('#pdf-preview-overlay iframe');
const print = vi.fn();
Object.defineProperty(iframe, 'contentWindow', { value: { print }, configurable: true });
const button = document.getElementById('pdf-print-btn');
expect(button?.textContent).toBe('pdf.saveAsPdf');
button?.click();
expect(print).toHaveBeenCalledTimes(1);
});
it('FE-PDFPREVIEW-002: the iOS Home Screen app gets a Safari link to the trip export and says why', () => {
standalone(true);
open();
expect(document.getElementById('pdf-print-btn')).toBeNull();
const link = document.getElementById('pdf-safari-link') as HTMLAnchorElement;
expect(link.textContent).toBe('pdf.openInSafari');
expect(link.target).toBe('_blank');
expect(link.rel).toBe('noopener');
const url = new URL(link.href);
expect(url.pathname).toBe('/trips/9');
expect(url.searchParams.get('export')).toBe('pdf');
expect(document.getElementById('pdf-safari-hint')?.textContent).toBe('pdf.safariHint');
});
it('FE-PDFPREVIEW-003: without a trip to hand over, the preview keeps the print button', () => {
standalone(true);
open({ tripId: undefined });
expect(document.getElementById('pdf-print-btn')).not.toBeNull();
expect(document.getElementById('pdf-safari-link')).toBeNull();
});
it('FE-PDFPREVIEW-004: the title is escaped and the close button removes the preview', () => {
open({ title: '<b>Lisbon</b>' });
const overlay = document.getElementById('pdf-preview-overlay');
expect(overlay?.querySelector('b')).toBeNull();
expect(overlay?.textContent).toContain('<b>Lisbon</b>');
document.getElementById('pdf-close-btn')?.click();
expect(document.getElementById('pdf-preview-overlay')).toBeNull();
});
it('FE-PDFPREVIEW-005: the page-break switch flips and remembers the choice', () => {
open();
const toggle = document.getElementById('pdf-daybreak-toggle');
expect(toggle?.getAttribute('aria-checked')).toBe('true');
toggle?.click();
expect(toggle?.getAttribute('aria-checked')).toBe('false');
expect(localStorage.getItem(PAGE_BREAK_KEY)).not.toBeNull();
});
});
+167
View File
@@ -0,0 +1,167 @@
import { escapeHtml } from '@trek/shared';
import { isIosStandalone } from '../../utils/iosDevice';
import { pdfExportUrl } from '../../utils/pdfSafariHandoff';
import { rememberPdfSwitch } from '../../utils/pdfSwitch';
/**
* Every day starts a new page by default. On a trip of short days that prints
* one sheet per handful of lines, so the preview offers the flowing layout the
* in-app view already uses, and remembers which one was picked (#1292).
*/
export const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day';
/**
* The notes of a flight, train or rental (#1571): printed by default, like the
* notes of a place, and a switch in the preview leaves them out for a reader who
* wants the plan without the fine print.
*/
export const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes';
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
rebuilt here rather than rendered — as static markup it would carry no
behaviour. Same geometry and the same tokens, so it reads as the same control. */
const TOGGLE_TRACK =
'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;';
const TOGGLE_KNOB =
'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);';
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)');
const knobOffset = (on: boolean) => (on ? '22px' : '2px');
const HEADER_ACTION =
'display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit';
type Translate = (key: string, params?: Record<string, string | number>) => string;
export interface PdfPreviewOptions {
/** The finished document, shown in a sandboxed srcdoc frame. */
html: string;
title: string;
/** The trip the document prints, for the hand-over to Safari; without it the preview only prints. */
tripId?: number;
tr: Translate;
breaksPerDay: boolean;
showTransportNotes: boolean;
/** The transport-notes switch only shows when there is a note to hide. */
hasTransportNotes: boolean;
}
/**
* The control that saves the document. A browser prints the frame and offers
* "Save as PDF" in its dialog. An iPhone or iPad running TREK from the Home
* Screen ignores print() altogether, so there the control opens the trip in
* Safari with the export dialog up, and a line under the header says why.
*/
function saveControl(tripId: number | undefined, tr: Translate): { html: string; hint: string | null } {
if (tripId == null || !isIosStandalone()) {
return {
html: `<button type="button" id="pdf-print-btn" style="${HEADER_ACTION}">${escapeHtml(tr('pdf.saveAsPdf'))}</button>`,
hint: null,
};
}
return {
// target=_blank leaves the installed app for Safari; a link, not script, so the tap itself opens it.
html: `<a id="pdf-safari-link" href="${escapeHtml(pdfExportUrl(tripId))}" target="_blank" rel="noopener" style="${HEADER_ACTION};text-decoration:none">${escapeHtml(tr('pdf.openInSafari'))}</a>`,
hint: tr('pdf.safariHint'),
};
}
/** Opens the document in a modal over the app, with its layout switches and the control that saves it. */
export function openPdfPreview({
html,
title,
tripId,
tr,
breaksPerDay,
showTransportNotes,
hasTransportNotes,
}: PdfPreviewOptions): void {
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
const overlay = document.createElement('div');
overlay.id = 'pdf-preview-overlay';
overlay.style.cssText =
'position:fixed;inset:0;background:var(--overlay);z-index:var(--z-modal);display:flex;align-items:center;justify-content:center;padding:8px;';
overlay.onclick = (e) => {
if (e.target === overlay) overlay.remove();
};
const card = document.createElement('div');
card.style.cssText =
'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);';
const save = saveControl(tripId, tr);
const header = document.createElement('div');
header.style.cssText =
'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;';
header.innerHTML = `
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(title)}</span>
<div style="display:flex;align-items:center;gap:8px">
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
${save.html}
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
`;
const iframe = document.createElement('iframe');
iframe.style.cssText = 'flex:1;width:100%;border:none;';
// No script runs inside the document (print is parent-initiated), so withhold
// allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals';
iframe.srcdoc = html;
card.appendChild(header);
if (save.hint) {
const hint = document.createElement('p');
hint.id = 'pdf-safari-hint';
hint.style.cssText =
'margin:0;padding:8px 16px;font-size:12px;line-height:1.4;color:var(--text-muted);background:var(--bg-secondary);border-bottom:1px solid var(--border-primary);flex-shrink:0;';
hint.textContent = save.hint;
card.appendChild(hint);
}
card.appendChild(iframe);
overlay.appendChild(card);
document.body.appendChild(overlay);
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn');
if (closeBtn) closeBtn.onclick = () => overlay.remove();
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn');
if (printBtn)
printBtn.onclick = () => {
iframe.contentWindow?.print();
};
// Both choices differ by one class on <body>, so switching is instant in the
// preview and there is no need to re-fetch photos or rebuild the document.
wirePreviewSwitch(header, 'pdf-daybreak-toggle', (on) => {
rememberPdfSwitch(PAGE_BREAK_KEY, on);
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on);
});
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', (on) => {
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on);
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on);
});
}
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
function previewSwitch(id: string, label: string, on: boolean): string {
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escapeHtml(label)}</label>
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escapeHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
<span style="${TOGGLE_KNOB} inset-inline-start:${knobOffset(on)}"></span>
</button>`;
}
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
const button = header.querySelector<HTMLButtonElement>(`#${id}`);
if (!button) return;
button.onclick = () => {
const on = button.getAttribute('aria-checked') !== 'true';
button.setAttribute('aria-checked', String(on));
button.style.background = trackColour(on);
const knob = button.firstElementChild as HTMLElement | null;
if (knob) knob.style.insetInlineStart = knobOffset(on);
onChange(on);
};
}
@@ -1,4 +1,4 @@
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-027
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF'
@@ -84,6 +84,12 @@ describe('DayPlanSidebarToolbar', () => {
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-027: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the dialog', () => {
render(<DayPlanSidebarToolbar {...makeProps()} />, { initialEntries: ['/trips/1?export=pdf'] })
expect(screen.getByRole('button', { name: 'dayplan.export' })).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
})
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
const user = userEvent.setup()
render(<DayPlanSidebarToolbar {...makeProps()} />)
@@ -6,6 +6,7 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { BarButton, PANEL_BAR } from './planParts'
import { useToast } from '../shared/Toast'
import { TripExportModal } from './TripExportModal'
import { useExportDeepLink } from '../../hooks/useExportDeepLink'
import { isRoutableReservation } from '../../utils/reservationRoutes'
import type { DayAddControls } from '../../utils/dayAdd'
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
@@ -63,6 +64,8 @@ export function DayPlanSidebarToolbar({
}: DayPlanSidebarToolbarProps) {
const [reorderOpen, setReorderOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
// The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
useExportDeepLink(() => setExportOpen(true))
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
const expandLabel = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
@@ -386,6 +386,27 @@ describe('MapSettingsTab – CARTO key', () => {
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
});
it('FE-COMP-MAP-032b: a managed instance without an operator token offers no Mapbox and no Amap tiles', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true, hasAmapKey: false });
seedStore(useSettingsStore, { settings: buildSettings({ map_provider: 'leaflet', mapbox_access_token: '', map_tile_url: '' }) });
render(<MapSettingsTab />);
expect(screen.queryByText('Mapbox GL')).not.toBeInTheDocument();
expect(screen.getByText('MapLibre GL')).toBeInTheDocument();
await user.click(screen.getByText('Select template...'));
expect(screen.queryByText('高德地图 (Amap)')).not.toBeInTheDocument();
expect(screen.getByText('OpenStreetMap')).toBeInTheDocument();
});
it('FE-COMP-MAP-032c: the operator token brings Mapbox back on a managed instance', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true });
seedStore(useSettingsStore, { settings: buildSettings({ map_provider: 'leaflet', mapbox_access_token: 'pk.operator' }) });
render(<MapSettingsTab />);
expect(screen.getByText('Mapbox GL')).toBeInTheDocument();
});
it('FE-COMP-MAP-033: the typed key is part of the save patch', async () => {
const user = userEvent.setup();
const updateSettings = vi.fn().mockResolvedValue(undefined);
@@ -15,7 +15,7 @@ import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
import { withTileApiKey } from '../../utils/tileUrl'
import { defaultStyleForProvider, getStylePresets, type GlMapProvider } from '../Map/glProviders'
import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel'
import { PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel'
import { useMapSettingsForm } from './useMapSettingsForm'
/** A field holding a URL, token or key: the box look in Geist, so the characters read apart. */
@@ -130,7 +130,7 @@ function ProviderTile({ active, onClick, icon: Icon, name, subtitle, badge }: {
export default function MapSettingsTab(): React.ReactElement {
const { t } = useTranslation()
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
managed, presets, canMapbox, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings,
} = useMapSettingsForm()
@@ -149,7 +149,7 @@ export default function MapSettingsTab(): React.ReactElement {
{/* Provider picker — big tiles so the choice is obvious */}
<SettingRows>
<SettingRow label={t('settings.mapProvider')} hint={t('settings.mapProviderHint')} stacked>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
<div className={`grid grid-cols-1 gap-2 ${canMapbox ? 'sm:grid-cols-3' : 'sm:grid-cols-2'}`}>
<ProviderTile
active={provider === 'leaflet'}
onClick={() => changeProvider('leaflet')}
@@ -157,15 +157,17 @@ export default function MapSettingsTab(): React.ReactElement {
name="Leaflet"
subtitle={t('settings.mapLeafletSubtitle')}
/>
<ProviderTile
active={provider === 'mapbox-gl'}
onClick={() => changeProvider('mapbox-gl')}
icon={Box}
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
subtitle={t('settings.mapMapboxSubtitle')}
// Only on ≥sm; on a narrow window there's no room next to the title.
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
/>
{canMapbox && (
<ProviderTile
active={provider === 'mapbox-gl'}
onClick={() => changeProvider('mapbox-gl')}
icon={Box}
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
subtitle={t('settings.mapMapboxSubtitle')}
// Only on ≥sm; on a narrow window there's no room next to the title.
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
/>
)}
<ProviderTile
active={provider === 'maplibre-gl'}
onClick={() => changeProvider('maplibre-gl')}
@@ -185,7 +187,7 @@ export default function MapSettingsTab(): React.ReactElement {
value={mapTileUrl}
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
options={presets.map(p => ({ value: p.url, label: p.name }))}
/>
<input
id={`${fieldId}-tile`}
@@ -0,0 +1,33 @@
// FE-MAP-MODEL-001 to FE-MAP-MODEL-004
import { describe, expect, it } from 'vitest';
import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults';
import { MAP_PRESETS, mapboxOffered, mapPresetsFor } from './mapSettingsModel';
const urls = (list: { url: string }[]) => list.map((p) => p.url);
describe('mapPresetsFor', () => {
it('FE-MAP-MODEL-001: a self-hosted install offers every preset, Amap included', () => {
expect(mapPresetsFor(false, false)).toEqual(MAP_PRESETS);
});
it('FE-MAP-MODEL-002: a managed install offers Amap only where the operator set it up', () => {
const without = urls(mapPresetsFor(true, false));
expect(without).not.toContain(AMAP_ROAD);
expect(without).not.toContain(AMAP_SATELLITE);
expect(without).toHaveLength(MAP_PRESETS.length - 2);
expect(mapPresetsFor(true, true)).toEqual(MAP_PRESETS);
});
});
describe('mapboxOffered', () => {
it('FE-MAP-MODEL-003: a self-hosted install always offers Mapbox, the token field follows', () => {
expect(mapboxOffered(false, null)).toBe(true);
expect(mapboxOffered(false, '')).toBe(true);
});
it('FE-MAP-MODEL-004: a managed install offers Mapbox only with the operator token', () => {
expect(mapboxOffered(true, undefined)).toBe(false);
expect(mapboxOffered(true, '')).toBe(false);
expect(mapboxOffered(true, 'pk.operator')).toBe(true);
});
});
@@ -41,6 +41,26 @@ export const MAP_PRESETS: MapPreset[] = [
{ name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE },
];
/**
* The presets this install offers. On a managed install Amap is the operator's
* service like every other keyed one: its tiles are offered only where the
* operator set Amap up (has_amap_key), so a hosted user is never pointed at a
* provider the instance does not use.
*/
export function mapPresetsFor(managed: boolean, hasAmapKey: boolean): MapPreset[] {
if (!managed || hasAmapKey) return MAP_PRESETS;
return MAP_PRESETS.filter((p) => p.url !== AMAP_ROAD && p.url !== AMAP_SATELLITE);
}
/**
* Whether Mapbox GL can be picked. A managed install brings the token with the
* instance and offers no field for one, so without an operator token the
* choice would only lead to a map that never loads.
*/
export function mapboxOffered(managed: boolean, token: string | null | undefined): boolean {
return !managed || !!token;
}
export type MapProvider = 'leaflet' | GlMapProvider;
export function normalizeProvider(value: unknown): MapProvider {
@@ -7,7 +7,15 @@ import type { Place } from '../../types';
import { normalizeStyleForProvider } from '../Map/glProviders';
import { useWebGL2 } from '../Map/glSupport';
import { useToast } from '../shared/Toast';
import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel';
import {
type MapProvider,
mapboxOffered,
mapPresetsFor,
normalizeProvider,
previewPlace,
slotStyle,
styleForProvider,
} from './mapSettingsModel';
export interface MapSettingsFormOptions {
/**
@@ -32,6 +40,7 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
const [provider, setProvider] = useState<MapProvider>(initialProvider);
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '');
const managed = useAuthStore((s) => s.managed);
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '');
const [cartoKey, setCartoKey] = useState<string>(settings.carto_api_key || '');
const [mapboxStyle, setMapboxStyle] = useState<string>(
@@ -94,6 +103,8 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
return {
managed,
presets: mapPresetsFor(managed, hasAmapKey),
canMapbox: mapboxOffered(managed, settings.mapbox_access_token),
saving,
provider,
changeProvider,
@@ -1,8 +1,8 @@
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-004
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-007
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
// imported as one, and when a track was recorded shown where it carried times.
import { fireEvent, render, screen } from '@testing-library/react';
import type { TourGpxPreviewTrack } from '@trek/shared';
import type { TourFileLeftOut, TourGpxPreviewTrack } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
import type { TourGpxImport } from './useTourGpxImport';
@@ -27,10 +27,10 @@ function track(overrides: Partial<TourGpxPreviewTrack> = {}): TourGpxPreviewTrac
};
}
function gpxImport(tracks: TourGpxPreviewTrack[]): TourGpxImport {
function gpxImport(tracks: TourGpxPreviewTrack[], leftOut?: TourFileLeftOut): TourGpxImport {
return {
importing: false,
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks }],
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks, leftOut }],
confirm: vi.fn(),
cancel: vi.fn(),
pick: vi.fn(),
@@ -63,19 +63,54 @@ describe('TourGpxPreviewDialog (ACT-01)', () => {
/>
);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' });
expect(current.confirm).toHaveBeenCalledWith(
{ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' },
{ points: false }
);
});
it('FE-GPX-PREVIEW-003: a city walk is imported as a city walk, and without a preset as a hike', () => {
const walk = gpxImport([track()]);
const { unmount } = render(<TourGpxPreviewDialog gpxImport={walk} initial={{ tour_type: 'walk' }} />);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' });
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' }, { points: false });
unmount();
const hike = gpxImport([track()]);
render(<TourGpxPreviewDialog gpxImport={hike} />);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' });
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: false });
});
});
describe('TourGpxPreviewDialog with points beside the lines (#2969)', () => {
it('FE-GPX-PREVIEW-005: offers the points as places, on by default, and imports them with the tour', () => {
const current = gpxImport([track()], { points: 4, areas: 0, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} />);
expect(screen.getByTestId('gpx-preview-points')).toBeInTheDocument();
// Added as places, they are not listed as left out.
expect(screen.queryByTestId('gpx-preview-left-out')).toBeNull();
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: true });
});
it('FE-GPX-PREVIEW-006: switched off, the points are named as left out and not imported', () => {
const current = gpxImport([track()], { points: 4, areas: 1, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} />);
fireEvent.click(screen.getByRole('button', { name: /tours\.import\.pointsAsPlaces|Add the points as places/ }));
expect(screen.getByTestId('gpx-preview-left-out')).toHaveTextContent(/tours\.import\.leftOutPoints|4 pins/);
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: false });
});
it('FE-GPX-PREVIEW-007: a file whose every track the trip holds can still bring its points', () => {
const current = gpxImport([track({ duplicate: true })], { points: 2, areas: 0, other: 0 });
render(<TourGpxPreviewDialog gpxImport={current} />);
const button = screen.getByTestId('gpx-preview-import');
expect(button).toBeEnabled();
expect(button).toHaveTextContent(/tours\.import\.pointsConfirm|Add places/);
expect(screen.queryByText(/tours\.import\.previewAllDuplicates|nothing to import/)).toBeNull();
fireEvent.click(button);
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: true });
});
});
@@ -1,6 +1,6 @@
import type { TourFileLeftOut, TourGpxImportFields } from '@trek/shared';
import { AlertTriangle, Ban, Clock, FileDown, Layers, MapPin, Shapes } from 'lucide-react';
import { useId, useMemo } from 'react';
import { useId, useMemo, useState } from 'react';
import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
import { useTranslation } from '../../i18n';
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { MapViewAuto } from '../Map/MapViewAuto';
import { SoftPill } from '../Planner/planParts';
import ToggleSwitch from '../Settings/ToggleSwitch';
import {
DialogButton,
DialogFooter,
@@ -98,8 +99,8 @@ function PreviewTrackRow({
);
}
/** What all the files held besides their lines, summed by kind; null when they held nothing else. */
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
/** What all the files held besides their lines, summed by kind. */
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut {
const sum = { points: 0, areas: 0, other: 0 };
for (const { leftOut } of files) {
if (!leftOut) continue;
@@ -107,18 +108,46 @@ function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
sum.areas += leftOut.areas;
sum.other += leftOut.other;
}
return sum.points + sum.areas + sum.other > 0 ? sum : null;
return sum;
}
/**
* IO-02: what a KML or KMZ file held besides its lines, named before the
* import so nothing goes missing unseen: a dashed card with each kind as a
* counted badge, and why in the info dot.
* #2969: the single points of the files (GPX waypoints, KML pins), which no
* tour is made of, offered as places of the trip. On by default: a file with
* a route and its stops is usually meant as both.
*/
function PreviewLeftOut({ files }: { files: readonly GpxPreviewFile[] }) {
function PreviewPoints({ count, on, onToggle }: { count: number; on: boolean; onToggle: () => void }) {
const { t } = useTranslation();
const leftOut = leftOutOf(files);
if (!leftOut) return null;
const hintId = useId();
return (
<div
data-testid="gpx-preview-points"
className="mt-2 flex items-center gap-2.5 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2"
>
<MapPin size={14} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex flex-wrap items-center gap-1.5 font-semibold text-content" style={fs(12.5, 'body')}>
{t('tours.import.pointsAsPlaces')}
<SoftPill>{t('tours.import.leftOutPoints', { count })}</SoftPill>
</span>
<span id={hintId} className="text-content-muted" style={fs(11.5, 'body')}>
{t('tours.import.pointsAsPlacesHint')}
</span>
</div>
<ToggleSwitch on={on} onToggle={onToggle} label={t('tours.import.pointsAsPlaces')} describedBy={hintId} />
</div>
);
}
/**
* IO-02: what a file held besides its lines and does not take in, named
* before the import so nothing goes missing unseen: a dashed card with each
* kind as a counted badge, and why in the info dot. Its points only while
* they are not added as places.
*/
function PreviewLeftOut({ leftOut }: { leftOut: TourFileLeftOut }) {
const { t } = useTranslation();
if (leftOut.points + leftOut.areas + leftOut.other === 0) return null;
const kinds = [
{ key: 'points', count: leftOut.points, icon: MapPin, labelKey: 'tours.import.leftOutPoints' },
{ key: 'areas', count: leftOut.areas, icon: Shapes, labelKey: 'tours.import.leftOutAreas' },
@@ -170,6 +199,29 @@ export default function TourGpxPreviewDialog({
const HIKE: TourGpxImportFields = { tour_type: 'hike' };
/** A fresh line needs its type complete; a file of known lines can still be confirmed for its points alone. */
function canConfirm(fresh: number, addsPoints: boolean, typeComplete: boolean): boolean {
return fresh > 0 ? typeComplete : addsPoints;
}
/** The points offered as places, and below them whatever else stays out. */
function PreviewExtras({
leftOut,
withPoints,
onTogglePoints,
}: {
leftOut: TourFileLeftOut;
withPoints: boolean;
onTogglePoints: () => void;
}) {
return (
<>
{leftOut.points > 0 && <PreviewPoints count={leftOut.points} on={withPoints} onToggle={onTogglePoints} />}
<PreviewLeftOut leftOut={withPoints ? { ...leftOut, points: 0 } : leftOut} />
</>
);
}
function PreviewDialog({
gpxImport,
files,
@@ -183,6 +235,9 @@ function PreviewDialog({
const labelId = useId();
const tileUrl = useTileUrl(OFM_POSITRON);
const choices = useGpxImportChoices(initial);
const leftOut = useMemo(() => leftOutOf(files), [files]);
const [withPoints, setWithPoints] = useState(true);
const addsPoints = withPoints && leftOut.points > 0;
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
@@ -194,7 +249,8 @@ function PreviewDialog({
[files]
);
const focusPoints = useMemo(() => lines.flat(), [lines]);
const canImport = fresh > 0 && importMissing(choices.chosen) === null && !gpxImport.importing;
const canImport = canConfirm(fresh, addsPoints, importMissing(choices.chosen) === null) && !gpxImport.importing;
const confirmLabel = fresh > 0 ? t('tours.import.previewConfirm', { count: fresh }) : t('tours.import.pointsConfirm');
const fields = importFields(choices.chosen);
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
@@ -231,9 +287,9 @@ function PreviewDialog({
data-testid="gpx-preview-import"
icon={<FileDown size={14} strokeWidth={2} />}
disabled={!canImport}
onClick={() => void gpxImport.confirm(fields)}
onClick={() => void gpxImport.confirm(fields, { points: addsPoints })}
>
{t('tours.import.previewConfirm', { count: fresh })}
{confirmLabel}
</DialogButton>
</DialogFooter>
}
@@ -258,8 +314,8 @@ function PreviewDialog({
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} number={index + 1} track={track} />
))}
</ul>
<PreviewLeftOut files={files} />
{fresh === 0 && (
<PreviewExtras leftOut={leftOut} withPoints={withPoints} onTogglePoints={() => setWithPoints((on) => !on)} />
{fresh === 0 && !addsPoints && (
<div className="mt-2">
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.import.previewAllDuplicates')}
@@ -6,6 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
import { useTripStore } from '../../store/tripStore'
import { placesApi } from '../../api/client'
import { tourGpxApi } from '../../api/tourGpx'
import ToursSidebar from './ToursSidebar'
import { pickFiles } from '../../../tests/helpers/filePicker'
@@ -457,6 +458,7 @@ describe('ToursSidebar', () => {
left_out: { points: 3, areas: 1, other: 0 },
})
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
const pinsCall = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 3 })
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
render(
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
@@ -469,9 +471,11 @@ describe('ToursSidebar', () => {
const rows = screen.getAllByTestId('gpx-preview-track')
expect(rows.map((row) => row.textContent?.slice(0, 1))).toEqual(['1', '2'])
expect(rows[1]).toHaveTextContent('No heights in the file')
// The pins are offered as places (#2969); only the area stays out.
expect(screen.getByTestId('gpx-preview-points')).toHaveTextContent('3 pins')
const leftOut = screen.getByTestId('gpx-preview-left-out')
expect(leftOut).toHaveTextContent('Not imported')
expect(leftOut).toHaveTextContent('3 pins')
expect(leftOut).not.toHaveTextContent('pins')
expect(leftOut).toHaveTextContent('1 area')
expect(leftOut).not.toHaveTextContent('other item')
expect(within(leftOut).getByRole('button', { name: 'Not imported' })).toBeInTheDocument()
@@ -479,6 +483,8 @@ describe('ToursSidebar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Import 2 tours' }))
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' }))
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
expect(pinsCall).toHaveBeenCalledWith(1, file, { points: true, paths: false })
expect(toast.success).toHaveBeenCalledWith('3 places added')
vi.restoreAllMocks()
})
@@ -121,7 +121,7 @@ describe('a ride taken from a GPX file (HOR-02)', () => {
expect(gpxImport.confirm).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('gpx-preview-import'));
expect(gpxImport.confirm).toHaveBeenCalledWith({ tour_type: 'horse' });
expect(gpxImport.confirm).toHaveBeenCalledWith({ tour_type: 'horse' }, { points: false });
});
it('FE-HORSE-IMPORT-005: a recorded ride in view carries the card, with the heights its file lacks named', () => {
@@ -5,7 +5,9 @@ import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores } from '../../../tests/helpers/store';
import { placesApi } from '../../api/client';
import { tourRepo } from '../../repo/tourRepo';
import { useTripStore } from '../../store/tripStore';
import { useTourGpxImport } from './useTourGpxImport';
// The toasts are caught at useToast, so no window global has to be set for them.
@@ -67,3 +69,66 @@ describe('useTourGpxImport without a line in the file', () => {
expect(addToast).toHaveBeenCalledWith('tours.import.unreadableIn', 'error', undefined);
});
});
describe('useTourGpxImport with points beside the lines (#2969)', () => {
const route = new File(['<gpx/>'], 'sygic.gpx');
const pins = new File(['<kml/>'], 'pins.kml');
const line = {
name: 'Route',
geometry: [[47, 11] as [number, number], [47.01, 11] as [number, number]],
point_count: 2,
distance_km: 1,
elevation_gain_m: null,
elevation_loss_m: null,
duplicate: false,
};
beforeEach(() => {
useTripStore.setState({ refreshPlaces: vi.fn(async () => {}) } as never);
vi.spyOn(tourRepo, 'importGpx').mockResolvedValue({ tours: [], skipped: 0, caution: false } as never);
});
const previewed = async (preview: (picked: File) => object) => {
vi.spyOn(tourRepo, 'previewGpx').mockImplementation(async (_tripId, picked) => preview(picked as File) as never);
const hook = renderHook(() => useTourGpxImport({ tripId: 2, canEdit: true }));
await act(() => hook.result.current.importFiles([route, pins]));
return hook;
};
it('adds the points of each file as places through the places import, waypoints only', async () => {
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
const importMap = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2 });
const { result } = await previewed((picked) => ({
tracks: [line],
left_out: { points: picked === route ? 3 : 2, areas: 0, other: 0 },
}));
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
expect(importGpx).toHaveBeenCalledWith(2, route, { waypoints: true, routes: false, tracks: false });
expect(importMap).toHaveBeenCalledWith(2, pins, { points: true, paths: false });
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
expect(addToast).toHaveBeenCalledWith('tours.import.pointsImported', 'success', undefined);
});
it('leaves the points out when they are not asked for, and skips the Tours import for a file of known lines', async () => {
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
const { result } = await previewed(() => ({
tracks: [{ ...line, duplicate: true }],
left_out: { points: 3, areas: 0, other: 0 },
}));
await act(() => result.current.confirm({ tour_type: 'hike' }));
expect(importGpx).not.toHaveBeenCalled();
expect(tourRepo.importGpx).not.toHaveBeenCalled();
});
it('names a file whose points could not be added, and still takes in its tour', async () => {
vi.spyOn(placesApi, 'importGpx').mockRejectedValue(new Error('offline'));
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 0 });
const { result } = await previewed((picked) => ({
tracks: [line],
...(picked === route ? { left_out: { points: 1, areas: 0, other: 0 } } : {}),
}));
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
expect(addToast).toHaveBeenCalledWith('tours.import.pointsFailedIn', 'error', undefined);
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
});
});
+71 -16
View File
@@ -40,10 +40,62 @@ function fileErrorMessage(error: unknown, name: string, t: Translate): string {
export interface GpxPreviewFile {
file: File;
tracks: TourGpxPreviewTrack[];
/** What a KML or KMZ file held besides its lines, which no tour is made of; absent for GPX. */
/** What the file held besides its lines, which no tour is made of: GPX waypoints, KML pins, areas and the rest. */
leftOut?: TourFileLeftOut;
}
/** What to do with the previewed files besides their lines. */
export interface TourImportExtras {
/** Add each file's points (GPX waypoints, KML pins) as places of the trip (#2969). */
points?: boolean;
}
interface ToursImported {
tours: TourListItem[];
skipped: number;
caution: boolean;
}
/** Each file with a line the trip does not hold yet, imported as tours; a file that fails is handed to `onError`. */
async function importTours(
tripId: number,
files: readonly GpxPreviewFile[],
fields: TourGpxImportFields,
onError: (error: unknown, file: File) => void
): Promise<ToursImported> {
const sum: ToursImported = { tours: [], skipped: 0, caution: false };
for (const { file, tracks } of files) {
if (tracks.every((track) => track.duplicate)) continue;
try {
const result = await tourRepo.importGpx(tripId, file, fields);
sum.tours.push(...result.tours);
sum.skipped += result.skipped;
sum.caution ||= result.caution;
} catch (error: unknown) {
onError(error, file);
}
}
return sum;
}
/** The points of each file that holds any, added as places (#2969); a file that fails is handed to `onError`. */
async function importPoints(
tripId: number,
files: readonly GpxPreviewFile[],
onError: (file: File) => void
): Promise<number> {
let places = 0;
for (const { file, leftOut } of files) {
if (!leftOut?.points) continue;
try {
places += await tourRepo.importFilePoints(tripId, file);
} catch {
onError(file);
}
}
return places;
}
interface TourGpxImportOptions {
tripId: number;
canEdit: boolean;
@@ -115,26 +167,29 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported,
[canEdit, fileError, importing, onNoTrack, preview, t, toast, tripId]
);
/** The previewed files imported as the chosen type, summed up in one set of toasts. */
/**
* The previewed files imported as the chosen type, summed up in one set of
* toasts. With `points`, what each file holds as single points comes in as
* places as well, so a file with a route and its stops loses neither (#2969).
* A file whose every line the trip already holds is not sent to the Tours
* import at all, only for its points.
*/
const confirm = useCallback(
async (fields: TourGpxImportFields) => {
async (fields: TourGpxImportFields, extras: TourImportExtras = {}) => {
if (!canEdit || !preview || importing) return;
setImporting(true);
const imported: TourListItem[] = [];
let skipped = 0;
let caution = false;
try {
for (const { file } of preview) {
try {
const result = await tourRepo.importGpx(tripId, file, fields);
imported.push(...result.tours);
skipped += result.skipped;
caution ||= result.caution;
} catch (error: unknown) {
fileError(error, file);
}
}
const { tours: imported, skipped, caution } = await importTours(tripId, preview, fields, fileError);
const places = extras.points
? await importPoints(tripId, preview, (file) =>
toast.error(t('tours.import.pointsFailedIn', { name: file.name }))
)
: 0;
setPreview(null);
if (places > 0) {
if (imported.length === 0) await refreshPlaces(tripId);
toast.success(t('tours.import.pointsImported', { count: places }));
}
if (imported.length > 0) {
// The server's place:created skips this socket, so the places are read
// again here for the new ones and their lines. Only the places: a whole
+27
View File
@@ -232,3 +232,30 @@ describe('help registry: helpers', () => {
expect(docsRoute({ slug: 'Creating-a-Trip', anchor: 'fields' })).toBe('/help/Creating-a-Trip#fields')
})
})
describe('help on a managed install', () => {
it('leaves out the guides and screens about what the operator runs', async () => {
const { useAuthStore } = await import('../store/authStore')
const { useAddonStore } = await import('../store/addonStore')
const reg = await import('./registry')
useAuthStore.setState({ managed: true })
try {
expect(reg.getHelpGuide('create-backup')).toBeNull()
expect(reg.getHelpGuide('instance-keys')).toBeNull()
expect(reg.getHelpContext('admin-backup')).toBeNull()
expect(reg.allHelpGuides().some(g => g.context === 'admin-storage')).toBe(false)
expect(reg.getHelpGuide('files-sync')).toBeNull()
// Integrations follow whether the instance has them on.
useAddonStore.setState({ addons: [] })
expect(reg.getHelpGuide('connect-dawarich')).toBeNull()
useAddonStore.setState({ addons: [{ id: 'dawarich', enabled: true } as never] })
expect(reg.getHelpGuide('connect-dawarich')).not.toBeNull()
// What a hosted admin can still do keeps its guide.
expect(reg.getHelpGuide('create-user')).not.toBeNull()
} finally {
useAuthStore.setState({ managed: false })
useAddonStore.setState({ addons: [] })
}
expect(reg.getHelpGuide('create-backup')).not.toBeNull()
})
})
+60 -7
View File
@@ -1,4 +1,6 @@
import type { HelpContext, HelpGuide } from './types'
import { useAddonStore } from '../store/addonStore'
import { useAuthStore } from '../store/authStore'
import { dashboardContext, dashboardGuides } from './contexts/dashboard'
import { vacayContext, vacayGuides } from './contexts/vacay'
import { atlasContext, atlasGuides } from './contexts/atlas'
@@ -74,36 +76,87 @@ const GUIDE_LIST: HelpGuide[] = [
export const HELP_CONTEXTS: ReadonlyMap<string, HelpContext> = new Map(CONTEXT_LIST.map(c => [c.id, c]))
export const HELP_GUIDES: ReadonlyMap<string, HelpGuide> = new Map(GUIDE_LIST.map(g => [g.id, g]))
/**
* What a managed install leaves out: the admin screens it hides (AdminPage) and
* the guides about what the operator runs there, such as keys, OIDC, place
* search, routing, backups, storage, releases and plugins. A guide that walks
* somebody through a panel they cannot open is worse than none.
*/
const MANAGED_HIDDEN_CONTEXTS: ReadonlySet<string> = new Set([
'admin-plugins', 'admin-storage', 'admin-github', 'admin-backup', 'settings-plugins',
])
const MANAGED_HIDDEN_GUIDES: ReadonlySet<string> = new Set([
'oidc', 'instance-keys', 'places-transit', 'rotate-jwt-secret', 'own-routing-engine',
'install-plugin', 'storage-backends', 'release-history', 'create-backup', 'auto-backup',
'plugin-settings', 'files-sync', 'files-sync-settings', 'document-providers',
])
/**
* Guides about an integration, shown on a managed install only while it is on
* there: the operator decides which integrations the instance has, and a guide
* to connect one it does not offer leads nowhere.
*/
const addonOn = (id: string) => (): boolean => useAddonStore.getState().isEnabled(id)
const MANAGED_FEATURE_GUIDES: Readonly<Record<string, () => boolean>> = {
'connect-dawarich': addonOn('dawarich'),
'dawarich-countries': addonOn('dawarich'),
'dawarich-wishes': addonOn('dawarich'),
'dawarich-stays': addonOn('dawarich'),
'journey-day-stays': addonOn('dawarich'),
'map-dawarich-trail': addonOn('dawarich'),
'connect-airtrail': addonOn('airtrail'),
'airtrail-import': addonOn('airtrail'),
'photo-providers': addonOn('memories'),
'mcp-oauth': addonOn('mcp'),
'api-keys': addonOn('mcp'),
'mcp-tokens-admin': addonOn('mcp'),
'tour-connect-wanderer': () => useAddonStore.getState().toursWanderer,
'tour-wanderer-watch': () => useAddonStore.getState().toursWanderer,
'tour-import-wanderer': () => useAddonStore.getState().toursWanderer,
}
/** Read at call time: `managed` arrives with app-config, after the registry loaded. */
const isManaged = (): boolean => useAuthStore.getState().managed
const shownContext = (c: HelpContext): boolean => !(isManaged() && MANAGED_HIDDEN_CONTEXTS.has(c.id))
const shownGuide = (g: HelpGuide): boolean => {
if (!isManaged()) return true
if (MANAGED_HIDDEN_GUIDES.has(g.id) || MANAGED_HIDDEN_CONTEXTS.has(g.context)) return false
const feature = MANAGED_FEATURE_GUIDES[g.id]
return feature ? feature() : true
}
export function getHelpContext(id: string | null | undefined): HelpContext | null {
return id ? (HELP_CONTEXTS.get(id) ?? null) : null
const context = id ? HELP_CONTEXTS.get(id) : undefined
return context && shownContext(context) ? context : null
}
export function getHelpGuide(id: string | null | undefined): HelpGuide | null {
return id ? (HELP_GUIDES.get(id) ?? null) : null
const guide = id ? HELP_GUIDES.get(id) : undefined
return guide && shownGuide(guide) ? guide : null
}
/** The guides of a context, in the order the context lists them. */
export function guidesFor(context: HelpContext): HelpGuide[] {
return context.guides.map(id => HELP_GUIDES.get(id)).filter((g): g is HelpGuide => g !== undefined)
return context.guides.map(id => HELP_GUIDES.get(id)).filter((g): g is HelpGuide => g !== undefined && shownGuide(g))
}
export function allHelpGuides(): HelpGuide[] {
return GUIDE_LIST
return isManaged() ? GUIDE_LIST.filter(shownGuide) : GUIDE_LIST
}
/** Every screen that has help, in registration order, for the screen switcher. */
export function allHelpContexts(): HelpContext[] {
return CONTEXT_LIST
return isManaged() ? CONTEXT_LIST.filter(shownContext) : CONTEXT_LIST
}
/** The screens that stand on their own; their sub-screens hang under them in the switcher. */
export function topLevelHelpContexts(): HelpContext[] {
return CONTEXT_LIST.filter(c => !c.parent)
return CONTEXT_LIST.filter(c => !c.parent && shownContext(c))
}
/** The sub-screens of a screen, in registration order. */
export function childHelpContexts(parentId: string): HelpContext[] {
return CONTEXT_LIST.filter(c => c.parent === parentId)
return CONTEXT_LIST.filter(c => c.parent === parentId && shownContext(c))
}
/** The screen and the screens above it, outermost first, for a breadcrumb. */
@@ -0,0 +1,49 @@
/**
* FE-EXPORTLINK-001 to FE-EXPORTLINK-003: `?export=pdf` opens the trip's export
* once, the way Safari lands after the iOS Home Screen app handed the PDF over.
*/
import { renderHook } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router';
import { describe, expect, it, vi } from 'vitest';
import { useExportDeepLink } from './useExportDeepLink';
function setup(entry: string) {
const openExport = vi.fn();
const wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter initialEntries={[entry]}>{children}</MemoryRouter>
);
const { result, rerender } = renderHook(
() => {
useExportDeepLink(openExport);
return useLocation();
},
{ wrapper }
);
return { openExport, result, rerender };
}
describe('useExportDeepLink', () => {
it('FE-EXPORTLINK-001: opens the export and drops the parameter, keeping the others', () => {
const { openExport, result, rerender } = setup('/trips/7?tab=plan&export=pdf');
expect(openExport).toHaveBeenCalledTimes(1);
expect(result.current.pathname).toBe('/trips/7');
expect(result.current.search).toBe('?tab=plan');
rerender();
expect(openExport).toHaveBeenCalledTimes(1);
});
it('FE-EXPORTLINK-002: leaves the trip alone without the parameter', () => {
const { openExport, result } = setup('/trips/7?tab=plan');
expect(openExport).not.toHaveBeenCalled();
expect(result.current.search).toBe('?tab=plan');
});
it('FE-EXPORTLINK-003: ignores an export it does not know', () => {
const { openExport, result } = setup('/trips/7?export=gpx');
expect(openExport).not.toHaveBeenCalled();
expect(result.current.search).toBe('?export=gpx');
});
});
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useEffectEvent } from 'react';
import { useSearchParams } from 'react-router';
import { EXPORT_PARAM, EXPORT_PDF } from '../utils/pdfSafariHandoff';
/**
* Opens the trip's export when the address asks for it (`?export=pdf`), the way
* the iOS Home Screen app hands a PDF over to Safari. The parameter is dropped
* once read, so a reload or a copied link does not open the dialog again.
*/
export function useExportDeepLink(openExport: () => void): void {
const [searchParams, setSearchParams] = useSearchParams();
const requested = searchParams.get(EXPORT_PARAM) === EXPORT_PDF;
// The shells pass a fresh closure on every render; the request is answered once.
const open = useEffectEvent(openExport);
useEffect(() => {
if (!requested) return;
open();
setSearchParams(
(p) => {
p.delete(EXPORT_PARAM);
return p;
},
{ replace: true }
);
}, [requested, setSearchParams]);
}
+1 -1
View File
@@ -88,7 +88,7 @@ export default function MAdmin() {
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon },
{ id: 'addons', label: t('admin.tabs.addons'), icon: Blocks },
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Puzzle },
...(managed ? [] : [{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Puzzle }]),
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive }]),
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell },
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: Plug }] : []),
@@ -63,6 +63,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
const {
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
toursWanderer,
wandererOffered,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
@@ -115,13 +116,15 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
{addon.id === 'documents' && addon.enabled && <MProviderShelf options={documentProviderOptions} />}
{addon.id === 'tours' && addon.enabled && (
<>
<MSubRow
providerIcon={WandererMark}
title={t('admin.toursWanderer.title')}
subtitle={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
{wandererOffered && (
<MSubRow
providerIcon={WandererMark}
title={t('admin.toursWanderer.title')}
subtitle={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
)}
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<MSubRow
key={key}
@@ -7,7 +7,7 @@ import { getApiErrorMessage, type DistanceUnit, type WeekStart } from '../../../
import { weekStartOptions } from '../../../utils/calendarWeek'
import { withTileApiKey } from '../../../utils/tileUrl'
import { defaultStyleForProvider } from '../../../components/Map/glProviders'
import { MAP_PRESETS, type MapProvider } from '../../../components/Settings/mapSettingsModel'
import type { MapProvider } from '../../../components/Settings/mapSettingsModel'
import { useDefaultUserSettings, type Defaults } from '../../../components/Admin/useDefaultUserSettings'
import MToggle from '../../components/MToggle'
import MSegmented from '../../components/MSegmented'
@@ -22,7 +22,7 @@ import RoutingInstanceFields from '../../../components/Admin/RoutingInstanceFiel
export default function MAdminDefaultUserSettings(): React.ReactElement {
const { t, locale } = useTranslation()
const {
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
defaults, loaded, managed, presets, canMapbox, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: getApiErrorMessage, previewCreatedAt: () => new Date().toISOString() })
@@ -80,14 +80,14 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
]
const providerOptions: { value: string; label: React.ReactNode }[] = [
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
...(canMapbox ? [{ value: 'mapbox-gl' as const, label: t('admin.defaultSettings.providerMapbox') }] : []),
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
]
const currencyLabel = defaults.default_currency
? (SYMBOLS[defaults.default_currency] ? `${defaults.default_currency} ${SYMBOLS[defaults.default_currency]}` : defaults.default_currency)
: t('settings.currency')
const tilePresetLabel = MAP_PRESETS.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
const tilePresetLabel = presets.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
const stylePresetLabel = glStylePresets.find((p) => p.url === mapboxStyle)?.name || t('admin.defaultSettings.mapboxStylePlaceholder')
const chevron = <ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
@@ -302,7 +302,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
title={t('settings.mapTemplate')}
value={mapTileUrl}
onSelect={pickTilePreset}
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
options={presets.map((p) => ({ value: p.url, label: p.name }))}
/>
<MSetPickerSheet
@@ -0,0 +1,66 @@
import type { useAdmin } from '../../../pages/admin/useAdmin';
import type { TranslationFn } from '../../../types';
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput } from './MAdminUi';
interface MAdminOidcCardProps {
admin: ReturnType<typeof useAdmin>;
t: TranslationFn;
onSave: () => void;
}
/** OIDC / SSO, as on the desktop settings tab. The settings section leaves it out on a managed install. */
export default function MAdminOidcCard({ admin, t, onSave }: MAdminOidcCardProps) {
const { oidcConfig, setOidcConfig, savingOidc } = admin;
return (
<MAdminCard>
<MAdminCardHead title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')} />
<div className="space-y-3">
<MAdminField label={t('admin.oidcDisplayName')}>
<MAdminInput
type="text"
value={oidcConfig.display_name}
onChange={(e) => setOidcConfig((c) => ({ ...c, display_name: e.target.value }))}
placeholder={t('admin.oidcDisplayNamePlaceholder')}
/>
</MAdminField>
<MAdminField label={t('admin.oidcIssuer')} hint={t('admin.oidcIssuerHint')}>
<MAdminInput
type="url"
value={oidcConfig.issuer}
onChange={(e) => setOidcConfig((c) => ({ ...c, issuer: e.target.value }))}
placeholder="https://accounts.google.com"
/>
</MAdminField>
<MAdminField
label={t('admin.oidcDiscoveryUrl')}
hint={t('admin.oidcDiscoveryUrlHint', { path: '<issuer>/.well-known/openid-configuration' })}
>
<MAdminInput
type="url"
value={oidcConfig.discovery_url}
onChange={(e) => setOidcConfig((c) => ({ ...c, discovery_url: e.target.value }))}
placeholder="https://auth.example.com/application/o/trek/.well-known/openid-configuration"
/>
</MAdminField>
<MAdminField label={t('settings.oauth.clientId')}>
<MAdminInput
type="text"
value={oidcConfig.client_id}
onChange={(e) => setOidcConfig((c) => ({ ...c, client_id: e.target.value }))}
/>
</MAdminField>
<MAdminField label={t('settings.oauth.clientSecret')}>
<MAdminInput
type="password"
value={oidcConfig.client_secret}
onChange={(e) => setOidcConfig((c) => ({ ...c, client_secret: e.target.value }))}
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
/>
</MAdminField>
<MAdminButton busy={savingOidc} onClick={onSave}>
{t('common.save')}
</MAdminButton>
</div>
</MAdminCard>
);
}
@@ -6,6 +6,7 @@ import type { TranslationFn } from '../../../types'
import type { useAdmin } from '../../../pages/admin/useAdmin'
import MToggle from '../../components/MToggle'
import { MBlockDisclosure, MProviderBlock, MTrekApiBlock } from './MApiProviderBlocks'
import MAdminOidcCard from './MAdminOidcCard'
import MSetPickerSheet from '../settings/MSetPickerSheet'
import {
MAdminButton,
@@ -31,7 +32,6 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled,
placesGoogleOnly, handleTogglePlacesGoogleOnly,
placeShadowEnabled,
oidcConfig, setOidcConfig, savingOidc,
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
envOverrideOidcOnly, oidcConfigured, requireMfa,
@@ -134,6 +134,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
{t('admin.passkey.notConfigured')}
</p>
)}
{/* The relying party is the instance's address, which the operator owns on a
managed install (server managed.ts), same as the desktop tab. */}
{!managed && (
<div className="space-y-3">
<MAdminField label={t('admin.passkey.rpId')} hint={t('admin.passkey.rpIdHint')}>
<MAdminInput
@@ -155,6 +158,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
{t('common.save')}
</MAdminButton>
</div>
)}
</MAdminCard>
{/* Require 2FA */}
@@ -187,7 +191,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
</div>
</MAdminCard>
{/* API keys */}
{/* API keys, place search and routing: the operator's on a managed install,
same as the desktop tab. Share place details has its own section there. */}
{!managed && (
<MAdminCard>
<MAdminCardHead title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')} />
<div className="space-y-3">
@@ -379,55 +385,10 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
)}
</div>
</MAdminCard>
)}
{/* OIDC / SSO */}
<MAdminCard>
<MAdminCardHead title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')} />
<div className="space-y-3">
<MAdminField label={t('admin.oidcDisplayName')}>
<MAdminInput
type="text"
value={oidcConfig.display_name}
onChange={(e) => setOidcConfig((c) => ({ ...c, display_name: e.target.value }))}
placeholder={t('admin.oidcDisplayNamePlaceholder')}
/>
</MAdminField>
<MAdminField label={t('admin.oidcIssuer')} hint={t('admin.oidcIssuerHint')}>
<MAdminInput
type="url"
value={oidcConfig.issuer}
onChange={(e) => setOidcConfig((c) => ({ ...c, issuer: e.target.value }))}
placeholder="https://accounts.google.com"
/>
</MAdminField>
<MAdminField label={t('admin.oidcDiscoveryUrl')} hint={t('admin.oidcDiscoveryUrlHint', { path: '<issuer>/.well-known/openid-configuration' })}>
<MAdminInput
type="url"
value={oidcConfig.discovery_url}
onChange={(e) => setOidcConfig((c) => ({ ...c, discovery_url: e.target.value }))}
placeholder="https://auth.example.com/application/o/trek/.well-known/openid-configuration"
/>
</MAdminField>
<MAdminField label={t('settings.oauth.clientId')}>
<MAdminInput
type="text"
value={oidcConfig.client_id}
onChange={(e) => setOidcConfig((c) => ({ ...c, client_id: e.target.value }))}
/>
</MAdminField>
<MAdminField label={t('settings.oauth.clientSecret')}>
<MAdminInput
type="password"
value={oidcConfig.client_secret}
onChange={(e) => setOidcConfig((c) => ({ ...c, client_secret: e.target.value }))}
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
/>
</MAdminField>
<MAdminButton busy={savingOidc} onClick={saveOidc}>
{t('common.save')}
</MAdminButton>
</div>
</MAdminCard>
{/* OIDC / SSO: the operator's on a managed install, as on the desktop tab. */}
{!managed && <MAdminOidcCard admin={admin} t={t} onSave={saveOidc} />}
{/* Danger zone */}
<MAdminCard className="border-[color:color-mix(in_srgb,var(--m-st-danger)_28%,transparent)]">
@@ -51,7 +51,7 @@ export default function MSettings() {
{ id: 'map', label: t('settings.tabs.map'), icon: Map },
{ id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell },
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug }] : []),
...(hasPlugins ? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle }] : []),
...(hasPlugins && !managed ? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle }] : []),
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
{ id: 'account', label: t('settings.tabs.account'), icon: User },
// Same call as the desktop page: About is about the project — what TREK is,
@@ -116,7 +116,7 @@ export default function MSettings() {
{active.id === 'notifications' && <MSettingsNotifications />}
{active.id === 'integrations' && hasIntegrations && <MSettingsIntegrations />}
{/* Per-plugin settings still reuse the existing responsive tab. */}
{active.id === 'plugins' && hasPlugins && <MSettingsPlugins />}
{active.id === 'plugins' && hasPlugins && !managed && <MSettingsPlugins />}
{active.id === 'offline' && <MSettingsOffline />}
{active.id === 'account' && <MSettingsAccount />}
{active.id === 'about' && appVersion && <MSettingsAbout appVersion={appVersion} />}
@@ -6,7 +6,7 @@ import { MapView } from '../../../components/Map/MapView'
import ErrorBoundary from '../../../components/shared/ErrorBoundary'
import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../../../components/Map/glLazy'
import { defaultStyleForProvider, getStylePresets } from '../../../components/Map/glProviders'
import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM, type MapProvider } from '../../../components/Settings/mapSettingsModel'
import { PREVIEW_CENTER, PREVIEW_ZOOM, type MapProvider } from '../../../components/Settings/mapSettingsModel'
import { useMapSettingsForm } from '../../../components/Settings/useMapSettingsForm'
import { withTileApiKey } from '../../../utils/tileUrl'
import MToggle from '../../components/MToggle'
@@ -20,7 +20,7 @@ import MSetPickerSheet from './MSetPickerSheet'
export default function MSettingsMap() {
const { t } = useTranslation()
const {
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
managed, presets: tilePresets, canMapbox, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save,
} = useMapSettingsForm({ mirrorStyleBeforeSave: true })
@@ -29,7 +29,7 @@ export default function MSettingsMap() {
const providers: { id: MapProvider; name: string; sub: string; icon: typeof Layers }[] = [
{ id: 'leaflet', name: 'Leaflet', sub: t('settings.mapLeafletSubtitle'), icon: Layers },
{ id: 'mapbox-gl', name: 'Mapbox GL', sub: t('settings.mapMapboxSubtitle'), icon: Box },
...(canMapbox ? [{ id: 'mapbox-gl' as const, name: 'Mapbox GL', sub: t('settings.mapMapboxSubtitle'), icon: Box }] : []),
{ id: 'maplibre-gl', name: 'MapLibre GL', sub: t('settings.mapMapLibreSubtitle'), icon: Globe2 },
]
@@ -73,7 +73,7 @@ export default function MSettingsMap() {
<>
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.mapTemplate')}</MSetEyebrow>
<MSetSelectRow
label={MAP_PRESETS.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')}
label={tilePresets.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')}
trailing={chevron}
onClick={() => setPresetOpen(true)}
/>
@@ -221,7 +221,7 @@ export default function MSettingsMap() {
title={t('settings.mapTemplate')}
value={mapTileUrl}
onSelect={setMapTileUrl}
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
options={tilePresets.map((p) => ({ value: p.url, label: p.name }))}
/>
<MSetPickerSheet
@@ -5,6 +5,7 @@ import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeMo
import { useTripStore } from '../../../../store/tripStore'
import { useTranslation } from '../../../../i18n'
import { useTripExport } from '../../../../components/Planner/useTripExport'
import { useExportDeepLink } from '../../../../hooks/useExportDeepLink'
import { INNER_CLS, TileHeader } from './MTripSheetUi'
import type { MTripSheetsProps } from '../MTripShell'
import type { LucideIcon } from 'lucide-react'
@@ -20,6 +21,8 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
const open = shell.sheet?.id === 'export'
const dayNotes = useTripStore(s => s.dayNotes)
const [subscribeOpen, setSubscribeOpen] = useState(false)
// The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
useExportDeepLink(() => shell.openSheet('export'))
// Fed the way the desktop dialog feeds it: the store's assignments, and the export
// applies the day plan's filter itself, so both shells print the same. The PDF stays
// open here; a calendar or GPX file closes the sheet.
+8
View File
@@ -1837,4 +1837,12 @@ describe('AdminPage', () => {
await screen.findByRole('button', { name: /^users$/i });
expect(screen.queryByRole('button', { name: /^storage$/i })).not.toBeInTheDocument();
});
it('FE-PAGE-ADMIN-STOR-003: managed mode hides the Plugins tab, and ?tab=plugins falls back to Users', async () => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
render(<AdminPage />, { initialEntries: ['/admin?tab=plugins'] });
await screen.findByRole('button', { name: /^users$/i });
expect(screen.queryByRole('button', { name: /^plugins$/i })).not.toBeInTheDocument();
await waitFor(() => expect(screen.queryByRole('heading', { name: /plugins/i })).not.toBeInTheDocument());
});
});
+3 -1
View File
@@ -60,7 +60,9 @@ function AdminPageDesktop(): React.ReactElement {
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal, group: gConfig },
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon, group: gConfig },
{ id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle, group: gConfig },
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig },
// Plugins run third-party code that can send data anywhere; a hosted instance
// offers none, so there is nothing to manage here (TREK_PLUGINS_ENABLED is off).
...(managed ? [] : [{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig }]),
// Storage backends and their credentials are hoster-level configuration —
// the server refuses the whole surface in managed mode (MANAGED_FORBIDDEN).
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive, group: gConfig }]),
+3 -5
View File
@@ -4,10 +4,9 @@ import { render, screen, waitFor, cleanup, within } from '../../tests/helpers/re
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { resetAllStores } from '../../tests/helpers/store';
import { seedJourneyOwner } from '../../tests/helpers/journeyDetail';
import { buildUser } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { usePermissionsStore } from '../store/permissionsStore';
import { useJourneyStore } from '../store/journeyStore';
import JourneyDetailPage from './JourneyDetailPage';
@@ -238,8 +237,7 @@ beforeEach(() => {
vi.clearAllMocks();
resetAllStores();
useJourneyStore.setState(useJourneyStore.getState(), true);
seedStore(useAuthStore, { isAuthenticated: true, user: ownerUser });
seedStore(usePermissionsStore, { level: 'owner' } as any);
seedJourneyOwner(ownerUser);
setupDefaultHandlers();
});
+2 -2
View File
@@ -38,7 +38,7 @@ function SettingsPageDesktop(): React.ReactElement {
// Always there: API keys sit in this tab and need no addon. The addon backed
// sections inside it gate themselves.
{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug },
...(hasPlugins
...(hasPlugins && !managed
? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Blocks }]
: []),
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
@@ -87,7 +87,7 @@ function SettingsPageDesktop(): React.ReactElement {
{activeTab === 'map' && <MapSettingsTab />}
{activeTab === 'notifications' && <NotificationsTab />}
{activeTab === 'integrations' && <IntegrationsTab />}
{activeTab === 'plugins' && hasPlugins && <PluginSettingsTab />}
{activeTab === 'plugins' && hasPlugins && !managed && <PluginSettingsTab />}
{activeTab === 'offline' && <OfflineTab />}
{activeTab === 'account' && <AccountTab />}
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
@@ -78,6 +78,21 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
</EditorField>
)
const tripRemindersCard = (
<SettingsCard
icon={CalendarClock}
title={t('admin.notifications.tripReminders.title')}
hint={t('admin.notifications.tripReminders.hint')}
action={
<ToggleSwitch
on={tripRemindersActive}
label={t('admin.notifications.tripReminders.title')}
onToggle={toggleTripReminders}
/>
}
/>
)
return (<>
{/* Two columns from xl up, the same shape as the Settings tab. Most of these
cards are a title with a switch on the far right, so on a wide screen the
@@ -168,19 +183,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
action={<LockedSwitch />}
/>
{/* Trip Reminders Toggle */}
<SettingsCard
icon={CalendarClock}
title={t('admin.notifications.tripReminders.title')}
hint={t('admin.notifications.tripReminders.hint')}
action={
<ToggleSwitch
on={tripRemindersActive}
label={t('admin.notifications.tripReminders.title')}
onToggle={toggleTripReminders}
/>
}
/>
{!managed && tripRemindersCard}
</div>
<div className="min-w-0">
@@ -284,6 +287,9 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
over, so it belongs under the targets it routes to rather than across the
full width below both columns. */}
<AdminNotificationsPanel t={t} toast={toast} />
{/* Without the admin alert cards the right column is short, so the
reminder switch moves over on a managed install. */}
{managed && tripRemindersCard}
</div>
</div>
@@ -320,24 +320,24 @@ describe('AdminSettingsTab', () => {
expect(admin.handleSavePlacesProvider).toHaveBeenCalledWith('amap');
});
it('FE-ADMSET-022e: a managed install hides the keys but keeps the provider choice', () => {
// The operator owns the credentials; which of them answers place search is
// still the admin's call (server managed.ts lists places_provider as theirs).
it('FE-ADMSET-022e: a managed install hides place search, routing and the keys', () => {
// All of it is the operator's there (server managed.ts locks places_provider);
// Share place details has a tab of its own on AdminPage.
renderTab({ managed: true, placesProvider: 'auto' });
expect(screen.queryByRole('heading', { name: 'API Keys' })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument();
expect(within(card(SERVICES)).getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: SERVICES })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Automatic' })).not.toBeInTheDocument();
});
it('FE-ADMSET-022f: an operator key keeps the managed install quiet', () => {
// The key fields are not on the page here, and an operator key set through
// the environment would not show up in them anyway. app-config knows.
renderTab({ managed: true, placesProvider: 'google', mapsKey: '', hasMapsKey: true });
it('FE-ADMSET-022f: a managed install moves file types to the right column', () => {
// With place search and the keys gone the right column would stand empty.
renderTab({ managed: true });
expect(
within(card(SERVICES)).queryByText(/falls back to OpenStreetMap/i),
).not.toBeInTheDocument();
const right = screen.getByRole('heading', { name: 'Allowed File Types' }).closest('.min-w-0');
expect(right).not.toBeNull();
expect(within(right as HTMLElement).queryByRole('heading', { name: /two-factor/i })).not.toBeInTheDocument();
});
it('FE-ADMSET-023: the maps Test button is disabled without a key', () => {
+35 -19
View File
@@ -16,6 +16,7 @@ import GoogleOptions from './GoogleOptions'
import GoogleDailyLimitRow from '../../components/Admin/GoogleDailyLimitRow'
import ProviderBlock from './ProviderBlock'
import TrekApiCard from './TrekApiCard'
import PlaceContribPanel from '../../components/Admin/PlaceContribPanel'
import { useAdminSettingsActions } from '../../components/Admin/useAdminSettingsActions'
import { placesGoogleOnlyHint } from '../../utils/placeSource'
import type { TranslationFn } from '../../types'
@@ -140,6 +141,29 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
const passwordLoginLocked = !passwordLogin && !oidcLogin
const oidcLoginLocked = !passwordLogin && oidcLogin
// On a managed install the right column loses place search, routing and the
// keys to the operator, so file types move over and the columns stay even.
const fileTypesCard = (
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
<div>
<input
type="text"
aria-label={t('admin.fileTypes')}
value={allowedFileTypes}
onChange={e => setAllowedFileTypes(e.target.value)}
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
className={`${INPUT} font-geist`}
/>
<SettingsHint className="mt-1.5">{t('admin.fileTypesFormat')}</SettingsHint>
</div>
<SaveBar
label={t('common.save')}
saving={savingFileTypes}
onClick={saveFileTypes}
/>
</SettingsCard>
)
return (
<div>
{/* Two columns from xl up. Nearly every card here is "label left,
@@ -351,33 +375,24 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</SettingsCard>
)}
{/* Allowed File Types */}
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
<div>
<input
type="text"
aria-label={t('admin.fileTypes')}
value={allowedFileTypes}
onChange={e => setAllowedFileTypes(e.target.value)}
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
className={`${INPUT} font-geist`}
/>
<SettingsHint className="mt-1.5">{t('admin.fileTypesFormat')}</SettingsHint>
</div>
<SaveBar
label={t('common.save')}
saving={savingFileTypes}
onClick={saveFileTypes}
/>
</SettingsCard>
{!managed && fileTypesCard}
</div>
<div className="min-w-0">
{managed && fileTypesCard}
{/* Inside the place search card elsewhere; that card is the operator's here,
sharing place details is still the admin's decision. */}
{managed && <PlaceContribPanel hideWhenSwitchedOff />}
{/* Place search and routing: TREK's own services first, then which provider
answers search and transit, and the search log. Apart from the keys, so a
keyless install reads its whole setup in one card. Everything here saves
as it is changed. */}
{/* On a managed install place search and routing are the operator's: the
services, their allowance and the providers behind them come with the
instance. Share place details stays the admin's call and has a tab of
its own there (AdminPage). */}
{!managed && (
<SettingsCard icon={Compass} title={t('admin.servicesCard.title')} hint={t('admin.servicesCard.hint')}>
<TrekApiCard t={t} managed={managed} />
{!managed && (<>
@@ -486,6 +501,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</SettingRows>
</SettingsCard>
)}
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
the operator's on a managed install, so the card is not shown there;
+1 -1
View File
@@ -33,7 +33,7 @@ const ADMIN_TAB_IDS = [
* hosted instance. Checked in an effect rather than in the initial state
* because `managed` arrives with /app-config, after the first render.
*/
const MANAGED_HIDDEN = ['storage', 'github', 'backup']
const MANAGED_HIDDEN = ['storage', 'github', 'backup', 'plugins']
/** How often the open users list asks again who is online and active. */
const USERS_REFRESH_MS = 30_000
+14 -1
View File
@@ -1,5 +1,5 @@
import { computeTourMetrics, estimatePaddleTime, paddleCheckSummaryOf, paddleLayoutOfTrack, paddleMovingSeconds, recommendedPaddleSpeedKmh, type GeometryPoint, type TourActivityAssignRequest, type TourObstacleCheck, type TourObstacleReviewsRequest, type TourPaddlePlanRequest, type TourSkiClassificationRequest, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint, walkStationFacts } from '@trek/shared'
import { toursApi } from '../api/client'
import { placesApi, toursApi } from '../api/client'
import { tourGpxApi } from '../api/tourGpx'
import { recordedTrackApi } from '../api/recordedTrack'
import { tourActivitiesApi } from '../api/tourActivities'
@@ -499,6 +499,19 @@ export const tourRepo = {
return result
},
/**
* The single points of a file (GPX waypoints, KML pins) added as places of
* the trip through the places import, which reads nothing else of it here
* (#2969). Online only, like the Tours import beside it. Answers how many
* places came in.
*/
async importFilePoints(tripId: number | string, file: File): Promise<number> {
const result = file.name.toLowerCase().endsWith('.gpx')
? await placesApi.importGpx(tripId, file, { waypoints: true, routes: false, tracks: false })
: await placesApi.importMapFile(tripId, file, { points: true, paths: false })
return result.count ?? 0
},
/** Reads a GPX, KML or KMZ file for its preview; online only, like the import, and nothing is cached. */
async previewGpx(tripId: number | string, file: File): Promise<TourGpxPreviewResponse> {
return tourGpxApi.preview(tripId, file)
+15
View File
@@ -0,0 +1,15 @@
/**
* FE-PDFSAFARI-001: the link the iOS Home Screen app hands to Safari.
*/
import { describe, expect, it } from 'vitest';
import { EXPORT_PARAM, EXPORT_PDF, pdfExportUrl } from './pdfSafariHandoff';
describe('pdfExportUrl', () => {
it('FE-PDFSAFARI-001: is the absolute trip address with the export asked for', () => {
const url = new URL(pdfExportUrl(42));
expect(url.origin).toBe(window.location.origin);
expect(url.pathname).toBe('/trips/42');
expect(url.searchParams.get(EXPORT_PARAM)).toBe(EXPORT_PDF);
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* An iPhone or iPad ignores print() in an app added to the Home Screen, in the
* page and in an iframe alike, so the trip PDF cannot be saved from there. Safari
* can print it. This is the link that hands the export over: it opens the trip in
* Safari with the export dialog already up.
*/
export const EXPORT_PARAM = 'export';
export const EXPORT_PDF = 'pdf';
/** The trip, opened with its export dialog showing. Absolute, since it leaves the installed app. */
export function pdfExportUrl(tripId: number): string {
return `${window.location.origin}/trips/${tripId}?${EXPORT_PARAM}=${EXPORT_PDF}`;
}
+17
View File
@@ -0,0 +1,17 @@
import { useAddonStore } from '../../src/store/addonStore';
import { useAuthStore } from '../../src/store/authStore';
import { usePermissionsStore } from '../../src/store/permissionsStore';
import type { User } from '../../src/types';
import { seedStore } from './store';
/**
* The install the journey page suite runs on: the owner is signed in with full
* rights, and a photo provider is on, so the entry editor offers External photos.
*/
export function seedJourneyOwner(user: User): void {
seedStore(useAuthStore, { isAuthenticated: true, user });
seedStore(usePermissionsStore, { level: 'owner' } as never);
seedStore(useAddonStore, {
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true } as never],
});
}
@@ -281,16 +281,16 @@ describe('MAdminSettingsSection', () => {
expect(admin.handleSaveApiKeys).toHaveBeenCalled();
});
it('FE-MOB-ASET-016b: a managed install hides the keys but keeps the provider choice', () => {
// The operator owns the credentials; which of them answers place search is
// still the admin's call, the same split the desktop tab makes.
it('FE-MOB-ASET-016b: a managed install hides the keys, place search, OIDC and the relying party', () => {
// All of it is the operator's there, the same split the desktop tab makes.
renderSettings({ managed: true });
expect(screen.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Amap/)).not.toBeInTheDocument();
expect(screen.queryByLabelText('Unsplash API Key')).not.toBeInTheDocument();
// The native select became TREK's own picker: a button carrying the choice.
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Automatic' })).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('trek.example.org')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText(/Authentik/)).not.toBeInTheDocument();
});
it('FE-MOB-ASET-016c: the missing-key notice follows app-config, not the fields', () => {
@@ -301,7 +301,7 @@ describe('MAdminSettingsSection', () => {
expect(screen.getByText(/TREK index and OpenStreetMap alone/i)).toBeInTheDocument();
unmount();
renderSettings({ managed: true, placesProvider: 'google', mapsKey: '', hasMapsKey: true });
renderSettings({ placesProvider: 'google', mapsKey: '', hasMapsKey: true });
expect(screen.queryByText(/TREK index and OpenStreetMap alone/i)).not.toBeInTheDocument();
});
@@ -9,7 +9,7 @@ import { server } from '../../../helpers/msw/server'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-EXPSH-001 to FE-MOB-EXPSH-015
// FE-MOB-EXPSH-001 to FE-MOB-EXPSH-016
// This sheet reads its copy from useTranslation(), so the assertions are English.
vi.mock('../../../../src/components/PDF/TripPDF', () => ({ downloadTripPDF: vi.fn() }))
@@ -248,4 +248,10 @@ describe('MExportSheet', () => {
expect(route?.signal?.aborted).toBe(true)
expect(signal?.aborted).toBe(true)
})
it('FE-MOB-EXPSH-016: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the sheet', () => {
const shell = buildShell({ sheet: null })
render(<MExportSheet planner={buildPlanner({ days: DAYS })} shell={shell} />, { initialEntries: ['/trips/1?export=pdf'] })
expect(shell.openSheet).toHaveBeenCalledWith('export')
})
})
+6
View File
@@ -114,6 +114,12 @@ export class AddonsService {
return this.writeFlag('ski_routing_enabled', enabled);
}
/** The photo providers users can connect: none while the journey addon is off. */
async enabledPhotoProviderIds(): Promise<string[]> {
if (!(await this.isAddonEnabled(ADDON_IDS.JOURNEY))) return [];
return this.photoProviders.listEnabledIds();
}
/** wanderer may be used: Tours is on and the admin allowed it under Tours. */
async isWandererAvailable(): Promise<boolean> {
return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled;
@@ -293,6 +293,7 @@ export class AdminController {
@Put('tours-wanderer')
@ResponseContract(adminFeatureToggleResponseSchema)
@ManagedForbidden('wanderer sends searches to a server the user picks, which the operator has not agreed to')
async updateToursWanderer(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updateToursWanderer(body.enabled);
await this.audit.writeAudit({
@@ -418,6 +419,7 @@ export class AdminController {
}
@Put('place-shadow')
@ManagedForbidden('place search belongs to the operator on a managed install')
async updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
const result = await this.addons.updatePlaceShadow(body.enabled);
await this.audit.writeAudit({
@@ -491,6 +493,7 @@ export class AdminController {
}
@Put('transit-provider')
@ManagedForbidden('routing belongs to the operator on a managed install')
async updateTransitProvider(@CurrentUser() user: User, @Body() body: AdminTransitProviderDto, @Req() req: Request) {
const result = await this.addons.updateTransitProvider(body.provider, user.id);
await this.audit.writeAudit({
@@ -266,6 +266,9 @@ export class AssistantToolSession {
/** The groups this turn loaded; the core is always there. */
private readonly loaded = new Set<string>();
/** Tools the model called before their group was loaded in this turn, by name, with that group. */
private readonly calledBlind = new Map<string, string>();
/** Whether the session offers its tools by group: a trip's turn and a home turn do. */
private get grouped(): boolean {
return this.mode === 'turn' || this.mode === 'home';
@@ -307,6 +310,13 @@ export class AssistantToolSession {
return tool?.offered ? tool.kind : null;
}
/** The group of a tool this grouped session holds but has not loaded in this turn; null otherwise. */
private unloadedGroupOf(name: string): string | null {
const group = this.tools.get(name)?.hint.group;
if (!this.grouped || !group || this.loaded.has(group)) return null;
return group;
}
/** The home chat offers a journal's page while the user's journals can be listed (the journal addon is on). */
private get offersJournalChat(): boolean {
return this.mode === 'home' && this.tools.has(OPEN_JOURNEY_CHAT_READ);
@@ -323,6 +333,15 @@ export class AssistantToolSession {
* conversation's is refused.
*/
pinArgs(name: string, args: ToolArgs): { args: ToolArgs } | { error: string } {
const unloaded = this.unloadedGroupOf(name);
if (unloaded) {
// The model called a tool it was not offered in this turn (it knows the
// name from an earlier turn), so it wrote the arguments without the
// schema (#2967). Its group is loaded here, so the next step offers the
// schema, and a refusal of these arguments says so (see call()).
this.loaded.add(unloaded);
this.calledBlind.set(name, unloaded);
}
const given = pinNamesOf(this.pinArg).filter((key) => args[key] !== undefined);
if (given.some((key) => Number(args[key]) !== this.pinnedId)) {
const about = this.pinArg === PINNED_ARG ? 'trip' : 'journey';
@@ -342,6 +361,15 @@ export class AssistantToolSession {
const final = name === TRANSIT_CREATE_TOOL ? await this.withFoundItinerary(args) : args;
const run = await this.invoke(name, final);
this.audit(name);
const blind = this.calledBlind.get(name);
if (!run.ok && blind) {
const hint = `${name} was called before the "${blind}" tools were loaded in this turn, so its arguments were written without its schema. They are loaded now and the schema is in your tools: send the call again with the arguments exactly as it describes them.`;
return {
ok: false,
value: `${typeof run.value === 'string' ? run.value : JSON.stringify(run.value)}
${hint}`,
};
}
if (name === TRANSIT_SEARCH_TOOL && run.ok) this.itineraries.keep(run.value);
return run;
}
@@ -89,7 +89,7 @@ export const APP_SETTINGS = {
webauthn_origins: { schema: text, default: null, adminForm: true, managedLocked: true },
// 'auto' (what every install had before Amap existed), 'google', 'amap' or
// 'openstreetmap'; the maps domain validates it (isPlacesProviderChoice).
places_provider: { schema: text, default: 'auto', adminForm: true, publicConfig: true },
places_provider: { schema: text, default: 'auto', adminForm: true, publicConfig: true, managedLocked: true },
// Read by name elsewhere.
oidc_issuer: { schema: text, default: null, env: (e) => e.oidc.issuer, publicConfig: true },
+3 -4
View File
@@ -91,6 +91,9 @@ export const MANAGED_LOCKED_SETTING_KEYS = [
'oidc_login',
'oidc_registration',
'openweather_api_key',
// Place search and routing are the operator's on a managed install: the
// services, their allowance and who answers come with the instance.
'places_provider',
'routing_base_url',
'routing_valhalla_provider',
// The operator issues a managed install its key through TREK_SERVICES_KEY.
@@ -149,10 +152,6 @@ export const MANAGED_CUSTOMER_KEYS = [
'passkey_login',
'password_login',
'password_registration',
// WHICH provider answers place search stays the admin's call even when the
// operator owns both credentials: it decides what their users see, not what
// the operator is billed for — the keys above cover that.
'places_provider',
'require_mfa',
'temperature_unit',
'time_format',
@@ -64,6 +64,9 @@ export function collectManagedRoutes(app: INestApplication): ManagedRouteEntry[]
*/
export const MANAGED_ROUTE_ALLOW_LIST: string[] = [
'AdminController.rotateJwtSecret',
'AdminController.updatePlaceShadow',
'AdminController.updateToursWanderer',
'AdminController.updateTransitProvider',
'AdminOidcController.get',
'AdminOidcController.update',
'AiAdminController.connection',
@@ -1,7 +1,10 @@
import { ADDON_IDS } from '../../addons';
import { readEnv } from '../../app-config';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { NotificationChannelPreferences } from '../../db/entities/NotificationChannelPreferences.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import type { NotificationChannelPreferencesRepository } from '../../db/repositories/NotificationChannelPreferences.repository';
import { AddonsService } from '../addons/addons.service';
import { readAppSetting } from '../common/app-settings.registry';
import { UnitOfWork } from '../database/unit-of-work';
import { listChannels } from './channel-registry';
@@ -60,6 +63,21 @@ const DEFAULT_KEY = (event: string, channel: string) => `notif_default_${event}_
* 3-column composite primary key (`user`, `event_type`, `channel`) via
* `em.upsert({ onConflictFields: [...] })`, per Task 0's R5 pin.
*/
/**
* The events this install can actually raise, for the preference matrices.
* Plugin notifications need the plugin runtime; on a managed install the
* release check and the storage replicas are the operator's, so their alerts
* would be rows nobody there can act on.
*/
export function offeredEventTypes(): NotifEventType[] {
const env = readEnv();
return ALL_EVENT_TYPES.filter((e) => {
if (e === 'plugin_notification') return env.plugins.enabled;
if (e === 'version_available' || e === 'replica_failure') return !env.managed.enabled;
return true;
});
}
@Injectable()
export class NotificationPreferencesService {
constructor(
@@ -68,8 +86,56 @@ export class NotificationPreferencesService {
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
@InjectRepository(NotificationChannelPreferences)
private readonly channelPrefs: NotificationChannelPreferencesRepository,
private readonly addons: AddonsService,
) {}
/**
* The events the matrices list: what the install can raise at all
* (offeredEventTypes), narrowed to the features that are switched on. A row
* for vacation sharing on an install without Vacay, or for Synology without
* the provider, is a switch that never does anything.
*/
private async listedEventTypes(): Promise<NotifEventType[]> {
const photoProviders = await this.addons.enabledPhotoProviderIds();
// One read per addon, shared by the events that hang off it.
const addonOn = new Map<string, Promise<boolean>>();
const on = (id: string): Promise<boolean> => {
if (!addonOn.has(id)) addonOn.set(id, this.addons.isAddonEnabled(id));
return addonOn.get(id) as Promise<boolean>;
};
const events = offeredEventTypes();
const shown = await Promise.all(events.map((event) => this.featureOn(event, on, photoProviders)));
return events.filter((_, i) => shown[i]);
}
private async featureOn(
event: NotifEventType,
on: (addonId: string) => Promise<boolean>,
photoProviders: string[],
): Promise<boolean> {
switch (event) {
case 'vacay_invite':
case 'vacay_share':
return on(ADDON_IDS.VACAY);
case 'collection_invite':
return on(ADDON_IDS.COLLECTIONS);
case 'collab_message':
return on(ADDON_IDS.COLLAB);
case 'packing_tagged':
return on(ADDON_IDS.PACKING);
case 'paddle_region_requested':
return on(ADDON_IDS.TOURS);
case 'photos_shared':
return photoProviders.length > 0;
case 'synology_session_cleared':
return photoProviders.includes('synologyphotos');
case 'wanderer_source_hint':
return this.addons.isWandererAvailable();
default:
return true;
}
}
/**
* Channels implemented for an event. In-app takes everything; external channels
* decide for themselves (today: everything except `synology_session_cleared`).
@@ -151,12 +217,12 @@ export class NotificationPreferencesService {
return (NOTIFICATION_DEFAULTS as readonly string[]).includes(raw ?? '') ? (raw as NotificationDefault) : 'on';
}
private userEventTypes(): NotifEventType[] {
return ALL_EVENT_TYPES.filter((e) => !ADMIN_SCOPED_EVENTS.has(e));
private async userEventTypes(): Promise<NotifEventType[]> {
return (await this.listedEventTypes()).filter((e) => !ADMIN_SCOPED_EVENTS.has(e));
}
async getInstanceDefaults(adminId: number): Promise<InstanceDefaultsMatrix> {
const event_types = this.userEventTypes();
const event_types = await this.userEventTypes();
const implemented_combos = await this.allCombos();
const defaults: InstanceDefaultsMatrix['defaults'] = {};
for (const event of event_types) {
@@ -171,7 +237,9 @@ export class NotificationPreferencesService {
async setInstanceDefaults(
defaults: Partial<Record<string, Partial<Record<string, NotificationDefault>>>>,
): Promise<void> {
const userEvents = new Set<string>(this.userEventTypes());
// Every user event, not only the listed ones: a default set while an addon is
// off is still the right default once it is switched on.
const userEvents = new Set<string>(ALL_EVENT_TYPES.filter((e) => !ADMIN_SCOPED_EVENTS.has(e)));
// The channel set is read before the transaction opens: listChannels() may
// ask the plugin runtime, which is no work to hold the connection for.
const combos = await this.allCombos();
@@ -298,8 +366,8 @@ export class NotificationPreferencesService {
// Filter event types by scope
const event_types =
scope === 'admin'
? ALL_EVENT_TYPES.filter((e) => ADMIN_SCOPED_EVENTS.has(e))
: ALL_EVENT_TYPES.filter((e) => !ADMIN_SCOPED_EVENTS.has(e));
? (await this.listedEventTypes()).filter((e) => ADMIN_SCOPED_EVENTS.has(e))
: (await this.listedEventTypes()).filter((e) => !ADMIN_SCOPED_EVENTS.has(e));
return {
preferences,
@@ -5,6 +5,7 @@ import { PushSubscriptions } from '../../db/entities/PushSubscriptions.entity';
import { Settings } from '../../db/entities/Settings.entity';
import { TodoItems } from '../../db/entities/TodoItems.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { AddonsModule } from '../addons/addons.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { StorageModule } from '../storage/storage.module';
import { MailerModule } from './mailer/mailer.module';
@@ -63,6 +64,7 @@ import { Module } from '@nestjs/common';
PushSubscriptions,
]),
AddonsModule,
MailerModule,
SchedulingModule,
StorageModule,
@@ -3,8 +3,9 @@
* A GPX file gives its tracks and routes, a KML or KMZ file its lines; both
* come back as the same rows, so the preview, the duplicate rule and the one
* transaction that stores them are the GPX import's own. Loose points are
* never a tour: a GPX file's waypoints are skipped as they always were, and a
* KML file's pins, areas and the rest are counted for the preview to name.
* never a tour: a GPX file's waypoints and a KML file's pins, areas and the
* rest are counted for the preview, which offers to add the points as places
* of the trip through the places import (#2969).
*/
import { DomainError } from '../common/domain-error';
import type { PlaceImportService } from '../place-import/place-import.service';
@@ -13,7 +14,7 @@ import type { TourFileLeftOut } from '@trek/shared';
type TourFileFormat = 'gpx' | 'kml' | 'kmz';
/** A file read for a Tours import: the rows it would store, and for a KML or KMZ file what it holds besides. */
/** A file read for a Tours import: the rows it would store, and what the file holds besides. */
export interface TourFileRead {
rows: PreparedGpxPlace[];
leftOut?: TourFileLeftOut;
@@ -56,7 +57,14 @@ export async function readTourFile(
importTracks: true,
defaultName: fileName,
});
return { rows };
if (rows.length === 0) return { rows };
const points = placeImport.readGpx(fileBuffer, {
importWaypoints: true,
importRoutes: false,
importTracks: false,
defaultName: fileName,
}).length;
return points > 0 ? { rows, leftOut: { points, areas: 0, other: 0 } } : { rows };
}
try {
const { tracks, leftOut } = await placeImport.readMapFileTracks(fileBuffer, fileName ?? '');
+2 -2
View File
@@ -943,8 +943,8 @@ export class ToursService {
/**
* PAD-02: what a file would import, read with the import's own parser and
* duplicate rule and written nowhere. Null when the file holds no track or
* route, the import's own refusal. A KML or KMZ file also says what it holds
* besides its lines (IO-02).
* route, the import's own refusal. The preview also says what the file holds
* besides its lines (IO-02), a GPX file its waypoints (#2969).
*/
async previewGpx(tripId: string, fileBuffer: Buffer, fileName?: string): Promise<TourGpxPreviewResponse | null> {
const { rows, leftOut } = await readTourFile(this.placeImport, fileBuffer, fileName);
+12
View File
@@ -1049,6 +1049,18 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
expect(broadcast).not.toHaveBeenCalled();
});
it('counts the waypoints beside the track, which no tour takes in (#2969)', async () => {
expect((await previewGpx(OWNER, TRACK_GPX)).body.left_out).toBeUndefined();
const withPoints = TRACK_GPX.replace(
'<trk>',
'<wpt lat="49.1" lon="12.1"><name>Hut</name></wpt><wpt lat="49.2" lon="12.2"><name>Lake</name></wpt><trk>',
);
const res = await previewGpx(OWNER, withPoints);
expect(res.body.tracks).toHaveLength(1);
expect(res.body.left_out).toEqual({ points: 2, areas: 0, other: 0 });
expect(await countRows(orm, Places)).toBe(0);
});
it('leaves heights it does not have unknown, and names a track the trip already holds', async () => {
const flat = await previewGpx(OWNER, FLAT_GPX);
expect(flat.body.tracks[0]).toMatchObject({ elevation_gain_m: null, elevation_loss_m: null });
+18 -1
View File
@@ -1,3 +1,4 @@
import type { AddonsService } from '../../src/nest/addons/addons.service';
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
import { NotificationPreferencesService } from '../../src/nest/notifications/notification-preferences.service';
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
@@ -62,7 +63,7 @@ export async function makeNotificationsService(
mailer,
new WebhookService(settingsRepo, appSettings),
new NtfyService(settingsRepo, appSettings),
new NotificationPreferencesService(mailer, uow, appSettings, channelPrefsRepo),
new NotificationPreferencesService(mailer, uow, appSettings, channelPrefsRepo, everyFeatureOn()),
uow,
notificationsRepo,
await makeWebPushService(db),
@@ -98,9 +99,25 @@ export async function makeNotificationPreferencesService(
await createTestUnitOfWork(db),
appSettings,
channelPrefsRepo,
everyFeatureOn(),
);
}
/**
* The addon answers for the preference matrices with every feature on, so a
* suite sees every event row unless it is about the narrowing itself.
*/
export function everyFeatureOn(
overrides: Partial<Pick<AddonsService, 'isAddonEnabled' | 'isWandererAvailable' | 'enabledPhotoProviderIds'>> = {},
): AddonsService {
return {
isAddonEnabled: async () => true,
isWandererAvailable: async () => true,
enabledPhotoProviderIds: async () => ['immich', 'synologyphotos'],
...overrides,
} as unknown as AddonsService;
}
/**
* A send that records instead of delivering.
*
@@ -375,6 +375,15 @@ describe('AddonsService.list', () => {
expect(listSpy).not.toHaveBeenCalled();
listSpy.mockRestore();
});
it('names the photo providers users can connect: the enabled ones, none while the journey addon is off', async () => {
await insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
await insertProvider({ id: 'off-provider', name: 'Off', icon: 'image', enabled: 0 });
expect(await svc.enabledPhotoProviderIds()).toEqual([]);
stubJourneyEnabled();
expect(await svc.enabledPhotoProviderIds()).toEqual(['immich']);
});
});
// Direct coverage of the enablement reads/writers relocated from
@@ -5,6 +5,7 @@ import type { AuditService } from '../../../src/nest/audit/audit.service';
import type { RegistrationInvitesService } from '../../../src/nest/auth/registration-invites.service';
import { KitineraryExtractorService } from '../../../src/nest/booking-import/kitinerary-extractor.service';
import { DomainError } from '../../../src/nest/common/domain-error';
import { MANAGED_FORBIDDEN } from '../../../src/nest/common/managed';
import type { NotificationsService } from '../../../src/nest/notifications/notifications.service';
import type { OauthService } from '../../../src/nest/oauth/oauth.service';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
@@ -607,3 +608,17 @@ describe('AdminController feature toggles', () => {
expect(noopInvalidate).not.toHaveBeenCalled();
});
});
describe('AdminController on a managed install', () => {
it('ADMIN-CTRL-MANAGED-001: the transit provider and the place search log are operator-owned', () => {
// Place search and routing come with a managed instance; the admin UI hides
// both, and the routes refuse them so a direct call cannot set them either.
for (const name of ['updateTransitProvider', 'updatePlaceShadow', 'updateToursWanderer'] as const) {
const handler = AdminController.prototype[name];
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, handler)?.reason, name).toBeTruthy();
}
// Reading stays open: the search still needs to know what it runs on.
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, AdminController.prototype.getTransitProvider)).toBeUndefined();
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, AdminController.prototype.getPlaceShadow)).toBeUndefined();
});
});
@@ -403,6 +403,28 @@ describe('an assistant tool session', () => {
}
});
it('ASTTOOL-024: a tool called before its group was loaded loads the group, and a refusal of its arguments says why (#2967)', async () => {
const { userId, tripId } = await ownerWithTrips();
const session = await tools.open(userId, tripId);
try {
expect(session.specs().map((spec) => spec.name)).not.toContain('list_tours');
// Called blind, as from an earlier turn: it still runs, and its group comes with it.
const pinned = session.pinArgs('get_tour_plan', { placeId: '530' });
expect(pinned).toEqual({ args: { placeId: '530' } });
expect(session.specs().map((spec) => spec.name)).toContain('list_tours');
// The MCP check refuses the string id; the answer tells the model to send it again.
const run = await session.call('get_tour_plan', { placeId: '530' });
expect(run.ok).toBe(false);
expect(String(run.value)).toContain('"tours" tools were loaded in this turn');
// A core tool's refusal carries no such hint.
const core = await session.call('create_place', {});
expect(core.ok).toBe(false);
expect(String(core.value)).not.toContain('were loaded in this turn');
} finally {
await session.close();
}
});
it('ASTTOOL-014: an undo session holds its target and nothing else', async () => {
const { userId, tripId } = await ownerWithTrips();
const session = await tools.open(userId, tripId, new Set(['delete_place']));
@@ -27,7 +27,7 @@ import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.ser
import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
import { createUser, createAdmin, setAppSetting, setNotificationChannels } from '../../helpers/factories';
import { makePlugin } from '../../helpers/factories/plugins';
import { makeNotificationsService } from '../../helpers/notifications';
import { everyFeatureOn, makeNotificationsService } from '../../helpers/notifications';
import { createTestNotificationChannelPreferencesRepo } from '../../helpers/notifications-repos';
import { createPluginRuntime } from '../../helpers/plugin-host';
import { resetTestDb } from '../../helpers/test-db';
@@ -114,6 +114,7 @@ beforeAll(async () => {
await createTestUnitOfWork(testDb),
await createTestAppSettingsRepo(testDb),
await createTestNotificationChannelPreferencesRepo(testDb),
everyFeatureOn(),
);
notifications = await makeNotificationsService(testDb);
});
@@ -68,6 +68,7 @@ describe('managed key assignment', () => {
'oidc_login',
'oidc_registration',
'openweather_api_key',
'places_provider',
'routing_base_url',
'routing_valhalla_provider',
'service_key',
@@ -0,0 +1,46 @@
/**
* Which events the preference matrices offer. A row for an event the install
* can never raise is a switch that does nothing: plugin notifications without
* the plugin runtime, and on a managed install the release check and the
* storage replicas, which belong to the operator there.
*/
import { ALL_EVENT_TYPES } from '../../../src/nest/notifications/notification-events';
import { offeredEventTypes } from '../../../src/nest/notifications/notification-preferences.service';
import { afterEach, describe, expect, it } from 'vitest';
const saved = { managed: process.env.TREK_MANAGED, plugins: process.env.TREK_PLUGINS_ENABLED };
afterEach(() => {
if (saved.managed === undefined) delete process.env.TREK_MANAGED;
else process.env.TREK_MANAGED = saved.managed;
if (saved.plugins === undefined) delete process.env.TREK_PLUGINS_ENABLED;
else process.env.TREK_PLUGINS_ENABLED = saved.plugins;
});
describe('offeredEventTypes', () => {
it('NOTIF-OFFERED-001: a self-hosted install with plugins offers every event', () => {
delete process.env.TREK_MANAGED;
delete process.env.TREK_PLUGINS_ENABLED;
expect(offeredEventTypes()).toEqual(ALL_EVENT_TYPES);
});
it('NOTIF-OFFERED-002: without the plugin runtime there is no plugin row', () => {
delete process.env.TREK_MANAGED;
process.env.TREK_PLUGINS_ENABLED = 'false';
expect(offeredEventTypes()).not.toContain('plugin_notification');
expect(offeredEventTypes()).toContain('version_available');
});
it('NOTIF-OFFERED-003: a managed install drops the operator alerts and keeps the rest in order', () => {
process.env.TREK_MANAGED = '1';
process.env.TREK_PLUGINS_ENABLED = 'false';
const offered = offeredEventTypes();
expect(offered).not.toContain('version_available');
expect(offered).not.toContain('replica_failure');
expect(offered).toContain('paddle_region_requested');
expect(offered).toEqual(
ALL_EVENT_TYPES.filter((e) => !['version_available', 'replica_failure', 'plugin_notification'].includes(e)),
);
});
});
@@ -19,7 +19,7 @@ import {
disableNotificationPref,
} from '../../helpers/factories';
import { countRows, findRow, insertRow } from '../../helpers/factories/rows';
import { makeWebPushService } from '../../helpers/notifications';
import { everyFeatureOn, makeWebPushService } from '../../helpers/notifications';
import { createTestNotificationChannelPreferencesRepo } from '../../helpers/notifications-repos';
import { resetTestDb } from '../../helpers/test-db';
import {
@@ -86,6 +86,9 @@ const getActiveChannels = (...a: Parameters<Svc['getActiveChannels']>) => svc.ge
const isSmtpConfigured = (...a: Parameters<Svc['isSmtpConfigured']>) => svc.isSmtpConfigured(...a);
const isWebhookConfigured = (...a: Parameters<Svc['isWebhookConfigured']>) => svc.isWebhookConfigured(...a);
/** The addon answers the service sees; a test narrows them and the next one gets them back. */
const features = everyFeatureOn();
beforeAll(async () => {
const usersRepo = await createTestUsersRepo(testDb);
const settingsRepo = await createTestSettingsRepo(testDb);
@@ -102,6 +105,7 @@ beforeAll(async () => {
await createTestUnitOfWork(testDb),
await createTestAppSettingsRepo(testDb),
await createTestNotificationChannelPreferencesRepo(testDb),
features,
);
});
@@ -450,3 +454,31 @@ describe('instance defaults', () => {
expect(await countRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'notif_default_%' } })).toBe(0);
});
});
describe('listed events follow the features that are on', () => {
it('NPREF-LIST-001: rows for switched-off addons and providers are not listed', async () => {
const all = { ...features };
Object.assign(features, {
isAddonEnabled: async (id: string) => id !== 'vacay' && id !== 'collections',
isWandererAvailable: async () => false,
enabledPhotoProviderIds: async () => ['immich'],
});
try {
const { user } = createUser(testDb);
const { event_types } = await svc.getPreferencesMatrix(user.id, 'user');
for (const hidden of [
'vacay_invite',
'vacay_share',
'collection_invite',
'wanderer_source_hint',
'synology_session_cleared',
]) {
expect(event_types).not.toContain(hidden);
}
expect(event_types).toContain('photos_shared');
expect(event_types).toContain('trip_invite');
} finally {
Object.assign(features, all);
}
});
});
@@ -20,10 +20,10 @@ const KML = Buffer.from(`<kml><Placemark><name>Coast</name>
<Placemark><Polygon/></Placemark></kml>`);
describe('readTourFile', () => {
it('TOUR-FILE-001: reads a GPX file for its tracks, never its waypoints, and counts nothing left out', async () => {
it('TOUR-FILE-001: reads a GPX file for its tracks, never its waypoints, and counts them as left out (#2969)', async () => {
const read = await readTourFile(buildPlaceImportService(), GPX, 'ridge.gpx');
expect(read.rows.map((row) => row.name)).toEqual(['ridge']);
expect(read.leftOut).toBeUndefined();
expect(read.leftOut).toEqual({ points: 1, areas: 0, other: 0 });
});
it('TOUR-FILE-002: reads a file without a known extension, or without a name, as GPX as before', async () => {
+3
View File
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'التكلفة',
'pdf.preview': 'معاينة PDF',
'pdf.saveAsPdf': 'حفظ كـ PDF',
'pdf.openInSafari': 'فتح في Safari',
'pdf.safariHint':
'لا يمكن لنظام iOS الطباعة من التطبيق على الشاشة الرئيسية. سيفتح Safari هذه الرحلة مع تجهيز التصدير، ويمكنك حفظ ملف PDF هناك.',
'pdf.pageBreakPerDay': 'فاصل صفحة لكل يوم',
'pdf.transportNotes': 'ملاحظات التنقل',
'pdf.mapTitle': 'نظرة عامة على المسار',
+10
View File
@@ -967,6 +967,16 @@ const tours: TranslationStrings = {
'tours.import.leftOutOther.two': 'عنصران آخران',
'tours.import.leftOutOther.few': '{count} عناصر أخرى',
'tours.import.leftOutOther.many': '{count} عنصرًا آخر',
'tours.import.pointsAsPlaces': 'أضف النقاط كأماكن',
'tours.import.pointsAsPlacesHint': 'النقاط المفردة ليست جزءًا من الجولة، بل تُضاف إلى أماكن الرحلة.',
'tours.import.pointsConfirm': 'أضف الأماكن',
'tours.import.pointsFailedIn': 'تعذّرت إضافة النقاط في {name} كأماكن.',
'tours.import.pointsImported': 'أُضيف {count} مكان',
'tours.import.pointsImported.zero': 'أُضيف {count} مكان',
'tours.import.pointsImported.one': 'أُضيف مكان واحد',
'tours.import.pointsImported.two': 'أُضيف مكانان',
'tours.import.pointsImported.few': 'أُضيفت {count} أماكن',
'tours.import.pointsImported.many': 'أُضيف {count} مكانًا',
'tours.import.unreadableIn': 'تعذّرت قراءة {name}: الملف تالف أو ليس GPX أو KML أو KMZ',
'tours.import.archiveNoKmlIn': 'لم يُعثر على ملف KML في {name}',
'tours.import.archiveTooLargeIn': '{name} كبير جدًا بعد فك الضغط',
+3
View File
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Xərc',
'pdf.preview': 'PDF önizləməsi',
'pdf.saveAsPdf': 'PDF kimi yadda saxla',
'pdf.openInSafari': 'Safari-də aç',
'pdf.safariHint':
'iOS Əsas ekrandakı tətbiqdən çap edə bilmir. Safari bu səfəri ixrac hazır vəziyyətdə açır, PDF-i orada saxlaya bilərsən.',
'pdf.pageBreakPerDay': 'Hər gün üçün yeni səhifə',
'pdf.transportNotes': 'Nəqliyyat qeydləri',
'pdf.mapTitle': 'Marşrutun icmalı',
+7
View File
@@ -956,6 +956,13 @@ const tours: TranslationStrings = {
'tours.import.leftOutAreas.one': '{count} sahə',
'tours.import.leftOutOther': '{count} digər element',
'tours.import.leftOutOther.one': '{count} digər element',
'tours.import.pointsAsPlaces': 'Nöqtələri məkan kimi əlavə et',
'tours.import.pointsAsPlacesHint':
'Tək nöqtələr turun bir hissəsi deyil. Onlar əvəzinə səyahətin məkanlarına əlavə olunur.',
'tours.import.pointsConfirm': 'Məkanları əlavə et',
'tours.import.pointsFailedIn': '{name} faylındakı nöqtələr məkan kimi əlavə edilə bilmədi.',
'tours.import.pointsImported': '{count} məkan əlavə edildi',
'tours.import.pointsImported.one': '{count} məkan əlavə edildi',
'tours.import.unreadableIn': '{name} oxunmadı: fayl zədələnib və ya GPX, KML, KMZ deyil',
'tours.import.archiveNoKmlIn': '{name} içində KML faylı tapılmadı',
'tours.import.archiveTooLargeIn': '{name} açıldıqdan sonra çox böyükdür',
+3
View File
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Custo',
'pdf.preview': 'Pré-visualização do PDF',
'pdf.saveAsPdf': 'Salvar como PDF',
'pdf.openInSafari': 'Abrir no Safari',
'pdf.safariHint':
'O iOS não consegue imprimir pelo app na Tela de Início. O Safari abre esta viagem com a exportação pronta, e lá você pode salvar o PDF.',
'pdf.pageBreakPerDay': 'Quebra de página por dia',
'pdf.transportNotes': 'Notas de transporte',
'pdf.mapTitle': 'Visão geral da rota',
+6
View File
@@ -962,6 +962,12 @@ const tours: TranslationStrings = {
'tours.import.leftOutAreas.one': '{count} área',
'tours.import.leftOutOther': '{count} outros itens',
'tours.import.leftOutOther.one': '{count} outro item',
'tours.import.pointsAsPlaces': 'Adicionar os pontos como lugares',
'tours.import.pointsAsPlacesHint': 'Pontos avulsos não fazem parte de um passeio. Eles entram nos lugares da viagem.',
'tours.import.pointsConfirm': 'Adicionar lugares',
'tours.import.pointsFailedIn': 'Não foi possível adicionar os pontos de {name} como lugares.',
'tours.import.pointsImported': '{count} lugares adicionados',
'tours.import.pointsImported.one': '{count} lugar adicionado',
'tours.import.unreadableIn': 'Não foi possível ler {name}: o arquivo está danificado ou não é GPX, KML nem KMZ',
'tours.import.archiveNoKmlIn': 'Nenhum arquivo KML encontrado em {name}',
'tours.import.archiveTooLargeIn': '{name} é grande demais depois de descompactado',
+3
View File
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Cost EUR',
'pdf.preview': 'Vista prèvia del PDF',
'pdf.saveAsPdf': 'Desa com a PDF',
'pdf.openInSafari': 'Obre a Safari',
'pdf.safariHint':
"iOS no pot imprimir des de l'app de la pantalla d'inici. Safari obre aquest viatge amb l'exportació a punt i allà pots desar el PDF.",
'pdf.pageBreakPerDay': 'Salt de pàgina per dia',
'pdf.transportNotes': 'Notes del transport',
'pdf.mapTitle': 'Resum de la ruta',
+6
View File
@@ -964,6 +964,12 @@ const tours: TranslationStrings = {
'tours.import.leftOutAreas.one': '{count} àrea',
'tours.import.leftOutOther': '{count} elements més',
'tours.import.leftOutOther.one': '{count} element més',
'tours.import.pointsAsPlaces': 'Afegeix els punts com a llocs',
'tours.import.pointsAsPlacesHint': 'Els punts solts no formen part de cap ruta. S’afegeixen als llocs del viatge.',
'tours.import.pointsConfirm': 'Afegeix llocs',
'tours.import.pointsFailedIn': 'No s’han pogut afegir els punts de {name} com a llocs.',
'tours.import.pointsImported': '{count} llocs afegits',
'tours.import.pointsImported.one': '{count} lloc afegit',
'tours.import.unreadableIn': "No s'ha pogut llegir {name}: el fitxer està malmès o no és GPX, KML ni KMZ",
'tours.import.archiveNoKmlIn': "No s'ha trobat cap fitxer KML a {name}",
'tours.import.archiveTooLargeIn': '{name} és massa gran un cop descomprimit',
+3
View File
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Náklady',
'pdf.preview': 'Náhled PDF',
'pdf.saveAsPdf': 'Uložit jako PDF',
'pdf.openInSafari': 'Otevřít v Safari',
'pdf.safariHint':
'iOS neumí tisknout z aplikace na ploše. Safari otevře tento výlet s připraveným exportem a tam si PDF uložíte.',
'pdf.pageBreakPerDay': 'Zalomení stránky pro každý den',
'pdf.transportNotes': 'Poznámky k dopravě',
'pdf.mapTitle': 'Přehled trasy',
+7
View File
@@ -950,6 +950,13 @@ const tours: TranslationStrings = {
'tours.import.leftOutOther': '{count} dalších položek',
'tours.import.leftOutOther.one': '{count} další položka',
'tours.import.leftOutOther.few': '{count} další položky',
'tours.import.pointsAsPlaces': 'Přidat body jako místa',
'tours.import.pointsAsPlacesHint': 'Samostatné body nejsou součástí trasy. Přidají se místo toho k místům cesty.',
'tours.import.pointsConfirm': 'Přidat místa',
'tours.import.pointsFailedIn': 'Body ze souboru {name} se nepodařilo přidat jako místa.',
'tours.import.pointsImported': '{count} míst přidáno',
'tours.import.pointsImported.one': '{count} místo přidáno',
'tours.import.pointsImported.few': '{count} místa přidána',
'tours.import.unreadableIn': 'Soubor {name} nelze přečíst: je poškozený nebo to není GPX, KML ani KMZ',
'tours.import.archiveNoKmlIn': 'V souboru {name} není žádný soubor KML',
'tours.import.archiveTooLargeIn': 'Soubor {name} je po rozbalení příliš velký',
+3
View File
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Kosten',
'pdf.preview': 'PDF Vorschau',
'pdf.saveAsPdf': 'Als PDF speichern',
'pdf.openInSafari': 'In Safari öffnen',
'pdf.safariHint':
'iOS kann aus der App auf dem Home-Bildschirm nicht drucken. Safari öffnet diese Reise mit dem Export, dort kannst du das PDF speichern.',
'pdf.pageBreakPerDay': 'Seitenumbruch pro Tag',
'pdf.transportNotes': 'Notizen zu Transporten',
'pdf.mapTitle': 'Routenübersicht',
+7
View File
@@ -972,6 +972,13 @@ const tours: TranslationStrings = {
'tours.import.leftOutAreas.one': '{count} Fläche',
'tours.import.leftOutOther': '{count} weitere Elemente',
'tours.import.leftOutOther.one': '{count} weiteres Element',
'tours.import.pointsAsPlaces': 'Punkte als Orte hinzufügen',
'tours.import.pointsAsPlacesHint':
'Einzelne Punkte gehören zu keiner Tour. Sie kommen stattdessen zu den Orten der Reise.',
'tours.import.pointsConfirm': 'Orte hinzufügen',
'tours.import.pointsFailedIn': 'Die Punkte aus {name} konnten nicht als Orte hinzugefügt werden.',
'tours.import.pointsImported': '{count} Orte hinzugefügt',
'tours.import.pointsImported.one': '{count} Ort hinzugefügt',
'tours.import.unreadableIn':
'{name} konnte nicht gelesen werden: Die Datei ist beschädigt oder kein GPX, KML oder KMZ',
'tours.import.archiveNoKmlIn': 'Keine KML-Datei in {name} gefunden',
+3
View File
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Cost',
'pdf.preview': 'PDF Preview',
'pdf.saveAsPdf': 'Save as PDF',
'pdf.openInSafari': 'Open in Safari',
'pdf.safariHint':
'iOS cannot print from the app on your Home Screen. Safari opens this trip with the export ready, and there you can save the PDF.',
'pdf.pageBreakPerDay': 'Page break per day',
'pdf.transportNotes': 'Transport notes',
'pdf.mapTitle': 'Route overview',
+6
View File
@@ -944,6 +944,12 @@ const tours: TranslationStrings = {
'tours.import.leftOutAreas.one': '{count} area',
'tours.import.leftOutOther': '{count} other items',
'tours.import.leftOutOther.one': '{count} other item',
'tours.import.pointsAsPlaces': 'Add the points as places',
'tours.import.pointsAsPlacesHint': 'Single points are not part of a tour. They join the places of the trip instead.',
'tours.import.pointsConfirm': 'Add places',
'tours.import.pointsFailedIn': 'The points in {name} could not be added as places.',
'tours.import.pointsImported': '{count} places added',
'tours.import.pointsImported.one': '{count} place added',
'tours.import.unreadableIn': 'Could not read {name}: the file is damaged or not GPX, KML or KMZ',
'tours.import.archiveNoKmlIn': 'No KML file found in {name}',
'tours.import.archiveTooLargeIn': '{name} is too large once unpacked',
+3
View File
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Coste',
'pdf.preview': 'Vista previa PDF',
'pdf.saveAsPdf': 'Guardar como PDF',
'pdf.openInSafari': 'Abrir en Safari',
'pdf.safariHint':
'iOS no puede imprimir desde la app de la pantalla de inicio. Safari abre este viaje con la exportación lista y allí puedes guardar el PDF.',
'pdf.pageBreakPerDay': 'Salto de página por día',
'pdf.transportNotes': 'Notas de transporte',
'pdf.mapTitle': 'Resumen de la ruta',
+7
View File
@@ -961,6 +961,13 @@ const tours: TranslationStrings = {
'tours.import.leftOutAreas.one': '{count} área',
'tours.import.leftOutOther': '{count} elementos más',
'tours.import.leftOutOther.one': '{count} elemento más',
'tours.import.pointsAsPlaces': 'Añadir los puntos como lugares',
'tours.import.pointsAsPlacesHint':
'Los puntos sueltos no forman parte de una ruta. Se añaden a los lugares del viaje.',
'tours.import.pointsConfirm': 'Añadir lugares',
'tours.import.pointsFailedIn': 'No se pudieron añadir los puntos de {name} como lugares.',
'tours.import.pointsImported': '{count} lugares añadidos',
'tours.import.pointsImported.one': '{count} lugar añadido',
'tours.import.unreadableIn': 'No se ha podido leer {name}: el archivo está dañado o no es GPX, KML ni KMZ',
'tours.import.archiveNoKmlIn': 'No hay ningún archivo KML en {name}',
'tours.import.archiveTooLargeIn': '{name} es demasiado grande una vez descomprimido',
+3
View File
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
'pdf.costLabel': 'Maksumus',
'pdf.preview': 'PDF-i eelvaade',
'pdf.saveAsPdf': 'Salvesta PDF-ina',
'pdf.openInSafari': 'Ava Safaris',
'pdf.safariHint':
'iOS ei saa avakuval olevast rakendusest printida. Safari avab selle reisi valmis ekspordiga ja seal saad PDF-i salvestada.',
'pdf.pageBreakPerDay': 'Iga päev uuel lehel',
'pdf.transportNotes': 'Transpordi märkmed',
'pdf.mapTitle': 'Marsruudi ülevaade',

Some files were not shown because too many files have changed in this diff Show More