diff --git a/client/src/api/client.ts b/client/src/api/client.ts index f74fa9d8..086294f4 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -487,6 +487,20 @@ export const addonsApi = { enabled: () => apiClient.get('/addons').then(r => r.data), } +export const airtrailApi = { + getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data), + saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) => + apiClient.put('/integrations/airtrail/settings', data).then(r => r.data), + status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data), + test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) => + apiClient.post('/integrations/airtrail/test', data).then(r => r.data), + sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data), + // flights + import are added with the trip-planner import (P2) + flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data), + import: (tripId: number, flightIds: string[]) => + apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data), +} + export const journeyApi = { list: () => apiClient.get('/journeys').then(r => r.data), create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data), diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 250978ba..458adbf5 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react' const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, } function ImmichIcon({ size = 14 }: { size?: number }) { diff --git a/client/src/components/Planner/AirTrailImportModal.tsx b/client/src/components/Planner/AirTrailImportModal.tsx new file mode 100644 index 00000000..4349887a --- /dev/null +++ b/client/src/components/Planner/AirTrailImportModal.tsx @@ -0,0 +1,261 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { useState, useRef, useEffect, useMemo } from 'react' +import { Plane, X, Check } from 'lucide-react' +import type { AirtrailFlight, AirtrailImportResult } from '@trek/shared' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import { airtrailApi, reservationsApi } from '../../api/client' +import { useTripStore } from '../../store/tripStore' + +interface AirTrailImportModalProps { + isOpen: boolean + onClose: () => void + tripId: number + pushUndo?: (label: string, undoFn: () => Promise | void) => void +} + +/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */ +function fmtDate(d: string | null, locale: string): string { + if (!d) return '' + try { + return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: 'UTC', + }) + } catch { + return d + } +} + +export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) { + const { t, locale } = useTranslation() + const toast = useToast() + const trip = useTripStore(s => s.trip) + const reservations = useTripStore(s => s.reservations) + const loadReservations = useTripStore(s => s.loadReservations) + const mouseDownTarget = useRef(null) + + const [loading, setLoading] = useState(false) + const [importing, setImporting] = useState(false) + const [error, setError] = useState('') + const [flights, setFlights] = useState([]) + const [selected, setSelected] = useState>(() => new Set()) + + // AirTrail flight ids already linked to a reservation in this trip. + const importedIds = useMemo(() => { + const set = new Set() + for (const r of reservations) { + if (r.external_source === 'airtrail' && r.external_id) set.add(String(r.external_id)) + } + return set + }, [reservations]) + + const inRange = (f: AirtrailFlight): boolean => + !!(f.date && trip?.start_date && trip?.end_date && f.date >= trip.start_date && f.date <= trip.end_date) + + useEffect(() => { + if (!isOpen) return + setError('') + setSelected(new Set()) + setLoading(true) + airtrailApi + .flights() + .then((d: { flights: AirtrailFlight[] }) => { + const list = d.flights ?? [] + setFlights(list) + // Pre-select the flights that fall inside the trip and aren't imported yet. + const pre = new Set() + for (const f of list) if (inRange(f) && !importedIds.has(f.id)) pre.add(f.id) + setSelected(pre) + }) + .catch((err: any) => setError(err?.response?.data?.error ?? t('reservations.airtrail.loadError'))) + .finally(() => setLoading(false)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]) + + const { during, others } = useMemo(() => { + const during: AirtrailFlight[] = [] + const others: AirtrailFlight[] = [] + for (const f of flights) (inRange(f) ? during : others).push(f) + const byDateDesc = (a: AirtrailFlight, b: AirtrailFlight) => (b.date ?? '').localeCompare(a.date ?? '') + return { during: during.sort(byDateDesc), others: others.sort(byDateDesc) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [flights, trip?.start_date, trip?.end_date]) + + const toggle = (id: string) => { + setSelected(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const handleClose = () => { onClose() } + + const handleImport = async () => { + const ids = [...selected].filter(id => !importedIds.has(id)) + if (ids.length === 0 || importing) return + setImporting(true) + setError('') + try { + const result: AirtrailImportResult = await airtrailApi.import(tripId, ids) + await loadReservations(tripId) + + const imported = result.imported ?? [] + if (imported.length > 0) { + pushUndo?.(t('reservations.airtrail.undo'), async () => { + const linked = useTripStore.getState().reservations.filter( + r => r.external_source === 'airtrail' && r.external_id && imported.includes(String(r.external_id)), + ) + await Promise.all(linked.map(r => reservationsApi.delete(tripId, r.id).catch(() => {}))) + await loadReservations(tripId) + }) + toast.success(t('reservations.airtrail.imported', { count: imported.length })) + } + + const skippedInTrip = (result.skipped ?? []).filter(s => s.reason === 'already-in-trip').length + if (skippedInTrip > 0) toast.warning(t('reservations.airtrail.skippedDuplicate', { count: skippedInTrip })) + if (imported.length === 0 && skippedInTrip === 0) toast.warning(t('reservations.airtrail.nothingImported')) + + handleClose() + } catch (err: any) { + setError(err?.response?.data?.error ?? t('reservations.airtrail.importError')) + } finally { + setImporting(false) + } + } + + const selectableCount = [...selected].filter(id => !importedIds.has(id)).length + + if (!isOpen) return null + + const renderFlight = (f: AirtrailFlight) => { + const already = importedIds.has(f.id) + const isSelected = selected.has(f.id) + const label = f.flightNumber ? `${f.airline ? `${f.airline} ` : ''}${f.flightNumber}` : `${f.fromCode ?? '?'} → ${f.toCode ?? '?'}` + return ( + + ) + } + + return ReactDOM.createPortal( +
{ mouseDownTarget.current = e.target }} + onClick={e => { + if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose() + mouseDownTarget.current = null + }} + > +
e.stopPropagation()} + className="bg-surface-card" + style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }} + > +
+ +
+ {t('reservations.airtrail.title')} +
+ +
+ +
+ {loading && ( +
+ {t('common.loading')} +
+ )} + + {!loading && flights.length === 0 && !error && ( +
+ {t('reservations.airtrail.empty')} +
+ )} + + {!loading && during.length > 0 && ( + <> +
+ {t('reservations.airtrail.duringTrip')} +
+ {during.map(renderFlight)} + + )} + + {!loading && others.length > 0 && ( + <> +
0 ? 14 : 2}px 0 8px` }}> + {t('reservations.airtrail.otherFlights')} +
+ {others.map(renderFlight)} + + )} + + {error && ( +
+ {error} +
+ )} +
+ +
+ + +
+
+
, + document.body, + ) +} diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 05c96798..aec9cb7f 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -179,6 +179,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo {t('reservations.needsReview')} ) : null} + {r.external_source === 'airtrail' ? ( + + + {r.sync_enabled ? t('reservations.airtrail.synced') : t('reservations.airtrail.notSynced')} + + ) : null}
void onImport?: () => void bookingImportAvailable?: boolean + onAirTrailImport?: () => void + airTrailAvailable?: boolean onEdit: (reservation: Reservation) => void onDelete: (id: number) => void onNavigateToFiles: () => void @@ -479,7 +491,7 @@ interface ReservationsPanelProps { addManualKey?: string } -export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) { +export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) { const { t, locale } = useTranslation() const can = useCanDo() const trip = useTripStore((s) => s.trip) @@ -602,6 +614,21 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme {t('reservations.import.cta')} )} + {onAirTrailImport && airTrailAvailable && ( + + )}
-
+
updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} /> @@ -510,6 +515,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
+
+ + updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} /> +
)} diff --git a/client/src/components/Settings/AirTrailConnectionSection.tsx b/client/src/components/Settings/AirTrailConnectionSection.tsx new file mode 100644 index 00000000..874e8af5 --- /dev/null +++ b/client/src/components/Settings/AirTrailConnectionSection.tsx @@ -0,0 +1,147 @@ +import React, { useEffect, useState } from 'react' +import { Plane, Save } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import { airtrailApi } from '../../api/client' +import Section from './Section' +import ToggleSwitch from './ToggleSwitch' + +/** + * Settings → Integrations → AirTrail. Per-user connection to a self-hosted + * AirTrail instance (URL + Bearer API key). Mirrors the photo-provider (Immich) + * connection layout: stacked fields, a toggle, then Save / Test-connection with + * a status badge. The key is stored encrypted and never prefilled. + */ +export default function AirTrailConnectionSection(): React.ReactElement { + const { t } = useTranslation() + const toast = useToast() + + const [url, setUrl] = useState('') + const [apiKey, setApiKey] = useState('') + const [allowInsecureTls, setAllowInsecureTls] = useState(false) + const [connected, setConnected] = useState(false) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + + useEffect(() => { + airtrailApi + .getSettings() + .then(d => { + setUrl(d.url || '') + setAllowInsecureTls(!!d.allowInsecureTls) + setConnected(!!d.connected) + }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + // Send the key only when the user typed a new one — never prefilled, so a blank + // field means "keep the stored key". + const keyPayload = (): { apiKey?: string } => { + const k = apiKey.trim() + return k ? { apiKey: k } : {} + } + + const handleSave = async () => { + setSaving(true) + try { + const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, ...keyPayload() }) + const status = await airtrailApi.status().catch(() => ({ connected: false })) + setConnected(!!status.connected) + setApiKey('') + if (d?.warning) toast.warning(d.warning) + else toast.success(t('settings.airtrail.toast.saved')) + } catch (err: any) { + toast.error(err?.response?.data?.error || t('settings.airtrail.toast.saveError')) + } finally { + setSaving(false) + } + } + + const handleTest = async () => { + setTesting(true) + try { + const d = await airtrailApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() }) + setConnected(!!d.connected) + if (d.connected) toast.success(t('settings.airtrail.test.success', { count: d.flightCount ?? 0 })) + else toast.error(d.error || t('settings.airtrail.test.failed')) + } catch { + toast.error(t('settings.airtrail.test.failed')) + } finally { + setTesting(false) + } + } + + const canSave = !!url.trim() && (connected || !!apiKey.trim()) + + return ( +
+
+
+ + setUrl(e.target.value)} + placeholder="https://airtrail.example.com" + className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" + /> +
+ +
+ + setApiKey(e.target.value)} + autoComplete="off" + placeholder={connected && !apiKey ? '••••••••' : t('settings.airtrail.apiKeyPlaceholder')} + className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" + /> +

{t('settings.airtrail.apiKeyHint')}

+
+ +
+ setAllowInsecureTls(v => !v)} /> + {t('settings.airtrail.allowInsecureTls')} +
+ +
+ + + {connected ? ( + + + {t('settings.airtrail.connected')} + + ) : ( + + + {t('settings.airtrail.notConnected')} + + )} +
+ +

{t('settings.airtrail.hint')}

+
+
+ ) +} diff --git a/client/src/components/Settings/IntegrationsTab.tsx b/client/src/components/Settings/IntegrationsTab.tsx index 51448b7e..54f58728 100644 --- a/client/src/components/Settings/IntegrationsTab.tsx +++ b/client/src/components/Settings/IntegrationsTab.tsx @@ -6,6 +6,7 @@ import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRigh import { authApi, oauthApi } from '../../api/client' import { useAddonStore } from '../../store/addonStore' import PhotoProvidersSection from './PhotoProvidersSection' +import AirTrailConnectionSection from './AirTrailConnectionSection' import { ALL_SCOPES } from '../../api/oauthScopes' import ScopeGroupPicker from '../OAuth/ScopeGroupPicker' @@ -97,6 +98,7 @@ export default function IntegrationsTab(): React.ReactElement { return ( <> + {S.airtrailEnabled && } {S.mcpEnabled && } @@ -109,6 +111,7 @@ function useIntegrations() { const toast = useToast() const { isEnabled: addonEnabled, loadAddons } = useAddonStore() const mcpEnabled = addonEnabled('mcp') + const airtrailEnabled = addonEnabled('airtrail') useEffect(() => { loadAddons() @@ -289,7 +292,7 @@ function useIntegrations() { return { - t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession, + t, locale, toast, mcpEnabled, airtrailEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession, } } diff --git a/client/src/hooks/useAirtrailConnection.ts b/client/src/hooks/useAirtrailConnection.ts new file mode 100644 index 00000000..2f879409 --- /dev/null +++ b/client/src/hooks/useAirtrailConnection.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react' +import { airtrailApi } from '../api/client' +import { useAddonStore } from '../store/addonStore' + +/** + * Resolves whether the current user can use AirTrail in a trip: the addon must + * be enabled globally AND the user must have a working connection. Drives the + * "AirTrail Import/Sync" button visibility in the Transport panel. + */ +export function useAirtrailConnection() { + const airtrailEnabled = useAddonStore(s => s.isEnabled('airtrail')) + const [connected, setConnected] = useState(false) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!airtrailEnabled) { + setConnected(false) + return + } + let cancelled = false + setLoading(true) + airtrailApi + .status() + .then(d => { if (!cancelled) setConnected(!!d.connected) }) + .catch(() => { if (!cancelled) setConnected(false) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [airtrailEnabled]) + + return { airtrailEnabled, connected, available: airtrailEnabled && connected, loading } +} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 7fd53588..0bdd5c72 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -18,6 +18,7 @@ import TripMembersModal from '../components/Trips/TripMembersModal' import { ReservationModal } from '../components/Planner/ReservationModal' import { TransportModal } from '../components/Planner/TransportModal' import BookingImportModal from '../components/Planner/BookingImportModal' +import AirTrailImportModal from '../components/Planner/AirTrailImportModal' // MemoriesPanel moved to Journey addon import ReservationsPanel from '../components/Planner/ReservationsPanel' import PackingListPanel from '../components/Packing/PackingListPanel' @@ -188,6 +189,7 @@ export default function TripPlannerPage(): React.ReactElement | null { showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, showReservationModal, setShowReservationModal, editingReservation, setEditingReservation, showBookingImport, setShowBookingImport, bookingImportAvailable, + airTrailAvailable, showAirTrailImport, setShowAirTrailImport, bookingForAssignmentId, setBookingForAssignmentId, showTransportModal, setShowTransportModal, editingTransport, setEditingTransport, transportModalDayId, setTransportModalDayId, @@ -634,6 +636,8 @@ export default function TripPlannerPage(): React.ReactElement | null { assignments={assignments} files={files} onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }} + onAirTrailImport={() => setShowAirTrailImport(true)} + airTrailAvailable={airTrailAvailable} onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }} onDelete={handleDeleteReservation} onNavigateToFiles={() => handleTabChange('dateien')} @@ -703,6 +707,7 @@ export default function TripPlannerPage(): React.ReactElement | null { { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} /> {showTransportModal && { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />} setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} /> + setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} /> setDeletePlaceId(null)} diff --git a/client/src/pages/settings/useSettings.ts b/client/src/pages/settings/useSettings.ts index 71f5dc8c..e030ee87 100644 --- a/client/src/pages/settings/useSettings.ts +++ b/client/src/pages/settings/useSettings.ts @@ -16,7 +16,8 @@ export function useSettings() { const memoriesEnabled = addonEnabled('memories') const mcpEnabled = addonEnabled('mcp') - const hasIntegrations = memoriesEnabled || mcpEnabled + const airtrailEnabled = addonEnabled('airtrail') + const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled const [appVersion, setAppVersion] = useState(null) const [activeTab, setActiveTab] = useState('display') diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 881dbb5b..9a757aa5 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -7,7 +7,7 @@ import { getCached, fetchPhoto } from '../../services/photoService' import { useToast } from '../../components/shared/Toast' import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react' import { useTranslation } from '../../i18n' -import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi } from '../../api/client' +import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client' import { accommodationRepo } from '../../repo/accommodationRepo' import { offlineDb } from '../../db/offlineDb' import { useAuthStore } from '../../store/authStore' @@ -16,6 +16,7 @@ import { useTripWebSocket } from '../../hooks/useTripWebSocket' import { useRouteCalculation } from '../../hooks/useRouteCalculation' import { usePlaceSelection } from '../../hooks/usePlaceSelection' import { usePlannerHistory } from '../../hooks/usePlannerHistory' +import { useAirtrailConnection } from '../../hooks/useAirtrailConnection' import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types' /** @@ -140,6 +141,18 @@ export function useTripPlanner() { const [editingReservation, setEditingReservation] = useState(null) const [showBookingImport, setShowBookingImport] = useState(false) const [bookingImportAvailable, setBookingImportAvailable] = useState(false) + const { available: airTrailAvailable } = useAirtrailConnection() + const [showAirTrailImport, setShowAirTrailImport] = useState(false) + // Pull this user's AirTrail edits as soon as they open the trip, so changes + // made in AirTrail show up without waiting for the background poll. + const airtrailSyncedRef = useRef(null) + useEffect(() => { + if (!airTrailAvailable || !tripId || airtrailSyncedRef.current === tripId) return + airtrailSyncedRef.current = tripId + airtrailApi.sync() + .then(r => { if (r && r.changed > 0) tripActions.loadReservations(tripId) }) + .catch(() => {}) + }, [airTrailAvailable, tripId, tripActions]) const [bookingForAssignmentId, setBookingForAssignmentId] = useState(null) const [showTransportModal, setShowTransportModal] = useState(false) const [editingTransport, setEditingTransport] = useState(null) @@ -666,6 +679,7 @@ export function useTripPlanner() { showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, showReservationModal, setShowReservationModal, editingReservation, setEditingReservation, showBookingImport, setShowBookingImport, bookingImportAvailable, + airTrailAvailable, showAirTrailImport, setShowAirTrailImport, bookingForAssignmentId, setBookingForAssignmentId, showTransportModal, setShowTransportModal, editingTransport, setEditingTransport, transportModalDayId, setTransportModalDayId, diff --git a/server/src/addons.ts b/server/src/addons.ts index a845f8c5..07a06ffe 100644 --- a/server/src/addons.ts +++ b/server/src/addons.ts @@ -7,6 +7,7 @@ export const ADDON_IDS = { ATLAS: 'atlas', COLLAB: 'collab', JOURNEY: 'journey', + AIRTRAIL: 'airtrail', } as const; export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS]; diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 7a758a64..787cd596 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -2460,6 +2460,35 @@ function runMigrations(db: Database.Database): void { if (after && after.region_code === row.region_code) del.run(row.id); } }, + () => { + // AirTrail integration addon — disabled by default (opt-in). Per-user connection + // lives in Settings → Integrations; this row is only the admin-level global toggle. + try { + db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)") + .run('airtrail', 'AirTrail', 'Sync flights from your self-hosted AirTrail instance', 'integration', 'Plane', 0, 14); + } catch (err: any) { + console.warn('[migrations] Non-fatal migration step failed:', err); + } + }, + () => { + // AirTrail per-user connection (mirrors the Immich integration columns). + try { db.exec("ALTER TABLE users ADD COLUMN airtrail_url TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec("ALTER TABLE users ADD COLUMN airtrail_api_key TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec("ALTER TABLE users ADD COLUMN airtrail_allow_insecure_tls INTEGER DEFAULT 0"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + }, + () => { + // AirTrail flight linkage on reservations (#214) — lets a TREK transport + // remember its AirTrail origin so the two-way sync can match + update it. + // sync_enabled flips to 0 when the AirTrail flight is deleted (row kept). + try { db.exec("ALTER TABLE reservations ADD COLUMN external_source TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec("ALTER TABLE reservations ADD COLUMN external_id TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec("ALTER TABLE reservations ADD COLUMN external_owner_user_id INTEGER"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec("ALTER TABLE reservations ADD COLUMN external_synced_at TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec("ALTER TABLE reservations ADD COLUMN sync_enabled INTEGER DEFAULT 1"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + try { db.exec("ALTER TABLE reservations ADD COLUMN external_hash TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } + // NULLs compare distinct in SQLite, so non-linked reservations don't collide. + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)"); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index ad86f771..c089d34b 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -98,6 +98,7 @@ function seedAddons(db: Database.Database): void { { id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 1, sort_order: 13 }, { id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 }, { id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 }, + { id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your self-hosted AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 }, ]; const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order); diff --git a/server/src/index.ts b/server/src/index.ts index 2504898e..7f6c7446 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -79,6 +79,7 @@ const onListen = () => { scheduler.startDemoReset(); scheduler.startIdempotencyCleanup(); scheduler.startTrekPhotoCacheCleanup(); + scheduler.startAirTrailSync(); const { startTokenCleanup } = require('./services/ephemeralTokens'); startTokenCleanup(); import('./websocket').then(({ setupWebSocket }) => { diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 977bedcc..4c1f7501 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -25,6 +25,7 @@ import { CollabModule } from './collab/collab.module'; import { FilesModule } from './files/files.module'; import { PhotosModule } from './photos/photos.module'; import { MemoriesModule } from './memories/memories.module'; +import { AirtrailModule } from './integrations/airtrail.module'; import { JourneyModule } from './journey/journey.module'; import { ShareModule } from './share/share.module'; import { SettingsModule } from './settings/settings.module'; @@ -41,10 +42,11 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; /** * Root NestJS module for the incremental migration. Domain modules - * (weather, notifications, ...) get registered here as they are migrated. + * (weather, notifications, integrations, ...) get registered here as they are + * migrated. */ @Module({ - imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule], + imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule], controllers: [HealthController], providers: [ HealthService, diff --git a/server/src/nest/integrations/airtrail-addon.guard.ts b/server/src/nest/integrations/airtrail-addon.guard.ts new file mode 100644 index 00000000..5ebe5fb7 --- /dev/null +++ b/server/src/nest/integrations/airtrail-addon.guard.ts @@ -0,0 +1,19 @@ +import { CanActivate, HttpException, Injectable } from '@nestjs/common'; +import { isAddonEnabled } from '../../services/adminService'; +import { ADDON_IDS } from '../../addons'; + +/** + * Gates the AirTrail integration routes on the global `airtrail` addon. When the + * admin has it disabled the whole group answers 404. Declared before the + * JwtAuthGuard so the addon check wins over the 401 (same ordering as the + * Journey addon gate). + */ +@Injectable() +export class AirtrailAddonGuard implements CanActivate { + canActivate(): boolean { + if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) { + throw new HttpException({ error: 'AirTrail addon is not enabled' }, 404); + } + return true; + } +} diff --git a/server/src/nest/integrations/airtrail-import.controller.ts b/server/src/nest/integrations/airtrail-import.controller.ts new file mode 100644 index 00000000..15c5dfb2 --- /dev/null +++ b/server/src/nest/integrations/airtrail-import.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, Headers, HttpException, Param, Post, UseGuards } from '@nestjs/common'; +import type { User } from '../../types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { AirtrailAddonGuard } from './airtrail-addon.guard'; +import { airtrailImportSchema, type AirtrailImport, type AirtrailImportResult } from '@trek/shared'; +import { verifyTripAccess } from '../../services/tripAccess'; +import { checkPermission } from '../../services/permissions'; +import { importAirtrailFlights } from '../../services/airtrail/airtrailImport'; + +/** + * POST /api/trips/:tripId/reservations/import/airtrail — turn selected AirTrail + * flights into reservations. Trip-scoped (reservation_edit) and addon-gated. The + * flights are re-fetched server-side with the caller's own key. + */ +@Controller('api/trips/:tripId/reservations/import') +@UseGuards(AirtrailAddonGuard, JwtAuthGuard) +export class AirtrailImportController { + private requireEdit(tripId: string, user: User): void { + const trip = verifyTripAccess(tripId, user.id); + if (!trip) throw new HttpException({ error: 'Trip not found' }, 404); + if (!checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id)) { + throw new HttpException({ error: 'No permission' }, 403); + } + } + + @Post('airtrail') + async importAirtrail( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Body(new ZodValidationPipe(airtrailImportSchema)) body: AirtrailImport, + @Headers('x-socket-id') socketId?: string, + ): Promise { + this.requireEdit(tripId, user); + try { + return await importAirtrailFlights(tripId, user.id, body.flightIds, socketId); + } catch (err: any) { + throw new HttpException({ error: err?.message || 'AirTrail import failed' }, err?.status === 400 ? 400 : 502); + } + } +} diff --git a/server/src/nest/integrations/airtrail.controller.ts b/server/src/nest/integrations/airtrail.controller.ts new file mode 100644 index 00000000..39558623 --- /dev/null +++ b/server/src/nest/integrations/airtrail.controller.ts @@ -0,0 +1,83 @@ +import { Body, Controller, Get, HttpCode, HttpException, Post, Put, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import type { User } from '../../types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { AirtrailAddonGuard } from './airtrail-addon.guard'; +import { getClientIp } from '../../services/auditLog'; +import { airtrailSettingsSchema, type AirtrailSettings } from '@trek/shared'; +import { + getConnectionSettings, + getConnectionStatus, + getFlightsForPicker, + saveSettings, + testConnection, +} from '../../services/airtrail/airtrailService'; +import { runAirtrailSyncForUser } from '../../services/airtrail/airtrailSync'; + +/** + * /api/integrations/airtrail — per-user AirTrail connection (#214). + * + * `status` and `test` answer 200 even on failure (the service shapes + * `{ connected: false, error }`); `settings` PUT validates with a 400. The API + * key is never echoed — `getSettings` returns it masked. The route group is + * gated on the `airtrail` addon (404 when disabled). + */ +@Controller('api/integrations/airtrail') +@UseGuards(AirtrailAddonGuard, JwtAuthGuard) +export class AirtrailController { + @Get('settings') + getSettings(@CurrentUser() user: User) { + return getConnectionSettings(user.id); + } + + @Put('settings') + async putSettings( + @CurrentUser() user: User, + @Body(new ZodValidationPipe(airtrailSettingsSchema)) body: AirtrailSettings, + @Req() req: Request, + ) { + const result = await saveSettings( + user.id, + body.url, + body.apiKey, + !!body.allowInsecureTls, + getClientIp(req), + ); + if (!result.success) { + throw new HttpException({ error: result.error }, 400); + } + return result.warning ? { success: true, warning: result.warning } : { success: true }; + } + + @Get('status') + getStatus(@CurrentUser() user: User) { + return getConnectionStatus(user.id); + } + + @Get('flights') + async flights(@CurrentUser() user: User) { + try { + return { flights: await getFlightsForPicker(user.id) }; + } catch (err: any) { + throw new HttpException({ error: err?.message || 'Could not load AirTrail flights' }, err?.status === 400 ? 400 : 502); + } + } + + /** Pull this user's AirTrail edits into their linked reservations on demand. */ + @Post('sync') + @HttpCode(200) + sync(@CurrentUser() user: User) { + return runAirtrailSyncForUser(user.id); + } + + @Post('test') + @HttpCode(200) + test( + @CurrentUser() user: User, + @Body(new ZodValidationPipe(airtrailSettingsSchema)) body: AirtrailSettings, + ) { + return testConnection(user.id, body.url, body.apiKey, !!body.allowInsecureTls); + } +} diff --git a/server/src/nest/integrations/airtrail.module.ts b/server/src/nest/integrations/airtrail.module.ts new file mode 100644 index 00000000..0feec084 --- /dev/null +++ b/server/src/nest/integrations/airtrail.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AirtrailController } from './airtrail.controller'; +import { AirtrailImportController } from './airtrail-import.controller'; + +/** + * AirTrail integration domain. The connection lives under + * /api/integrations/airtrail; the flight import is trip-scoped under + * /api/trips/:tripId/reservations/import/airtrail. Business logic lives in + * services/airtrail/* (plain functions over better-sqlite3). + */ +@Module({ + controllers: [AirtrailController, AirtrailImportController], +}) +export class AirtrailModule {} diff --git a/server/src/nest/reservations/reservations.controller.ts b/server/src/nest/reservations/reservations.controller.ts index 9a31bb81..efa38710 100644 --- a/server/src/nest/reservations/reservations.controller.ts +++ b/server/src/nest/reservations/reservations.controller.ts @@ -14,6 +14,7 @@ import type { User } from '../../types'; import { ReservationsService } from './reservations.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; +import { pushReservationToAirtrail } from '../../services/airtrail/airtrailSync'; type ReservationBody = Record & { title?: string; @@ -115,6 +116,11 @@ export class ReservationsController { const cur = current as { title: string; type?: string }; this.reservations.syncBudgetOnUpdate(tripId, id, body.title ?? '', body.type, cur.title, cur.type, body.create_budget_entry, socketId); this.reservations.broadcast(tripId, 'reservation:updated', { reservation }, socketId); + // Push a locally-edited AirTrail flight back to AirTrail (fire-and-forget, + // under the importer's credentials — see airtrailSync). #214 + if ((reservation as any)?.external_source === 'airtrail' && (reservation as any)?.sync_enabled) { + void pushReservationToAirtrail(Number((reservation as any).id), Number(tripId)).catch(() => {}); + } this.reservations.notifyBookingChange(tripId, user, body.title || cur.title, body.type || cur.type || ''); return { reservation }; } diff --git a/server/src/scheduler.ts b/server/src/scheduler.ts index 12d81539..39899573 100644 --- a/server/src/scheduler.ts +++ b/server/src/scheduler.ts @@ -334,6 +334,31 @@ function startTrekPhotoCacheCleanup(): void { }); } +// AirTrail sync: poll connected instances on an interval and reconcile linked +// flights both ways (#214). The per-tick enable gate (addon + setting) lives in +// runAirtrailSync, so toggling the addon takes effect without a restart. +let airtrailSyncTask: ScheduledTask | null = null; + +function startAirTrailSync(): void { + if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; } + + const { db } = require('./db/database'); + const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value; + const raw = parseInt(getSetting('airtrail_poll_interval_minutes') || '5', 10); + const minutes = Number.isFinite(raw) && raw >= 1 && raw <= 59 ? raw : 5; + const tz = process.env.TZ || 'UTC'; + logInfo(`AirTrail sync: scheduled every ${minutes}m`); + + airtrailSyncTask = cron.schedule(`*/${minutes} * * * *`, async () => { + try { + const { runAirtrailSync } = require('./services/airtrail/airtrailSync'); + await runAirtrailSync(); + } catch (err: unknown) { + logError(`AirTrail sync tick failed: ${err instanceof Error ? err.message : err}`); + } + }, { timezone: tz }); +} + function stop(): void { if (currentTask) { currentTask.stop(); currentTask = null; } if (demoTask) { demoTask.stop(); demoTask = null; } @@ -341,6 +366,7 @@ function stop(): void { if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; } if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; } if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; } + if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; } } -export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS }; +export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS }; diff --git a/server/src/services/airtrail/airtrailClient.ts b/server/src/services/airtrail/airtrailClient.ts new file mode 100644 index 00000000..fbab295a --- /dev/null +++ b/server/src/services/airtrail/airtrailClient.ts @@ -0,0 +1,197 @@ +import { safeFetch } from '../../utils/ssrfGuard'; + +/** + * Thin HTTP client for the AirTrail REST API (github.com/johanohly/AirTrail). + * This is the ONLY place that talks to a user's AirTrail instance. + * + * Verified against AirTrail source: + * - Auth: `Authorization: Bearer `; a key maps to exactly one user. + * - GET /api/flight/list — defaults to scope=mine. We NEVER send a scope + * param so the key only ever returns its owner's own flights (isolation + * holds even if an admin key is pasted). + * - GET /api/flight/get/{id} + * - POST /api/flight/save — `id` present => update, else create. seats[] is + * required (>=1). A seat with userId '' is attributed to the key + * owner server-side, so we never need the caller's AirTrail user id. + * - There is no webhook and no updated_at on a flight, so change detection is + * snapshot-hash based (see airtrailSync). + */ + +const TIMEOUT_MS = 12000; + +export interface AirtrailCreds { + /** Instance origin without a trailing /api. */ + baseUrl: string; + apiKey: string; + allowInsecureTls: boolean; +} + +export class AirtrailAuthError extends Error { + constructor(message = 'AirTrail rejected the API key') { + super(message); + this.name = 'AirtrailAuthError'; + } +} + +export class AirtrailRequestError extends Error { + status?: number; + constructor(message: string, status?: number) { + super(message); + this.name = 'AirtrailRequestError'; + this.status = status; + } +} + +export interface AirtrailAirport { + id: number; + icao: string | null; + iata: string | null; + name: string | null; + lat: number | null; + lon: number | null; + tz: string | null; + country: string | null; +} + +export interface AirtrailSeat { + userId: string | null; + guestName: string | null; + seat: string | null; + seatNumber: string | null; + seatClass: string | null; +} + +/** Airline/aircraft come back as joined objects (not bare codes) on a flight. */ +export interface AirtrailNamedCode { + id?: number; + icao?: string | null; + iata?: string | null; + name?: string | null; +} + +/** A flight as returned by list/get (the fields TREK consumes). */ +export interface AirtrailFlightRaw { + id: number; + from: AirtrailAirport | null; + to: AirtrailAirport | null; + date: string | null; + datePrecision: string | null; + departure: string | null; + arrival: string | null; + airline: AirtrailNamedCode | null; + flightNumber: string | null; + aircraft: AirtrailNamedCode | null; + aircraftReg: string | null; + flightReason: string | null; + note: string | null; + seats: AirtrailSeat[]; +} + +/** Write shape accepted by POST /flight/save (airports/airline/aircraft as codes). */ +export interface AirtrailSavePayload { + id?: number; + from: string; + to: string; + departure: string; + departureTime?: string | null; + arrival?: string | null; + arrivalTime?: string | null; + datePrecision?: string; + airline?: string | null; + flightNumber?: string | null; + aircraft?: string | null; + aircraftReg?: string | null; + flightReason?: string | null; + note?: string | null; + seats: Array<{ + userId: string | null; + guestName: string | null; + seat: string | null; + seatNumber: string | null; + seatClass: string | null; + }>; +} + +function apiBase(baseUrl: string): string { + // Tolerate a pasted trailing slash or '/api' suffix so we never build '/api/api'. + const origin = baseUrl.trim().replace(/\/+$/, '').replace(/\/api$/i, ''); + return origin + '/api'; +} + +/** + * Parse a response as JSON, but turn the cryptic "Unexpected token '<'" that a + * misconfigured URL produces (AirTrail serving its SPA / an auth-proxy login + * page) into an actionable message. + */ +async function parseJson(resp: Response): Promise { + const text = await resp.text(); + try { + return JSON.parse(text) as T; + } catch { + throw new AirtrailRequestError( + 'AirTrail returned a non-JSON response. Check the URL is your AirTrail base URL (e.g. https://airtrail.example.com, without /api) and that the instance is reachable without a separate login.', + ); + } +} + +async function request(creds: AirtrailCreds, path: string, init: RequestInit): Promise { + const url = apiBase(creds.baseUrl) + path; + let resp: Response; + try { + resp = await safeFetch( + url, + { + ...init, + headers: { + Authorization: `Bearer ${creds.apiKey}`, + Accept: 'application/json', + ...(init.headers || {}), + }, + signal: AbortSignal.timeout(TIMEOUT_MS) as any, + }, + { rejectUnauthorized: !creds.allowInsecureTls }, + ); + } catch (err: unknown) { + throw new AirtrailRequestError(err instanceof Error ? err.message : 'Could not reach AirTrail'); + } + if (resp.status === 401 || resp.status === 403) { + throw new AirtrailAuthError(); + } + return resp; +} + +export async function listFlights(creds: AirtrailCreds): Promise { + const resp = await request(creds, '/flight/list', { method: 'GET' }); + if (!resp.ok) throw new AirtrailRequestError(`AirTrail list failed (HTTP ${resp.status})`, resp.status); + const data = await parseJson<{ flights?: AirtrailFlightRaw[] }>(resp); + return data.flights ?? []; +} + +export async function getFlight(creds: AirtrailCreds, id: number): Promise { + const resp = await request(creds, `/flight/get/${id}`, { method: 'GET' }); + if (resp.status === 404) return null; + if (!resp.ok) throw new AirtrailRequestError(`AirTrail get failed (HTTP ${resp.status})`, resp.status); + const data = await parseJson<{ flight?: AirtrailFlightRaw }>(resp); + return data.flight ?? null; +} + +export async function saveFlight(creds: AirtrailCreds, payload: AirtrailSavePayload): Promise<{ id?: number }> { + const resp = await request(creds, '/flight/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + let msg = `AirTrail save failed (HTTP ${resp.status})`; + try { + const body = (await resp.json()) as { message?: string; errors?: unknown }; + if (body?.message) msg = body.message; + else if (body?.errors) msg = JSON.stringify(body.errors); + } catch { + /* keep the generic message */ + } + throw new AirtrailRequestError(msg, resp.status); + } + const data = await parseJson<{ id?: number }>(resp); + return { id: data.id }; +} diff --git a/server/src/services/airtrail/airtrailImport.ts b/server/src/services/airtrail/airtrailImport.ts new file mode 100644 index 00000000..d6b387a3 --- /dev/null +++ b/server/src/services/airtrail/airtrailImport.ts @@ -0,0 +1,132 @@ +import type { AirtrailImportResult } from '@trek/shared'; +import { db } from '../../db/database'; +import { broadcast } from '../../websocket'; +import { createReservation } from '../reservationService'; +import { getAirtrailCredentials } from './airtrailService'; +import { AirtrailRequestError, listFlights } from './airtrailClient'; +import { canonicalHash, mapFlightToReservation } from './airtrailMapper'; + +interface ExistingFlightRow { + id: number; + reservation_time: string | null; + metadata: string | null; + from_code: string | null; + to_code: string | null; +} + +function depDate(t: string | null): string | null { + return t && /^\d{4}-\d{2}-\d{2}/.test(t) ? t.slice(0, 10) : null; +} + +/** A loose "same physical flight" key: flight number + date, else route + date. */ +function softSignature( + date: string | null, + flightNumber: string | null, + fromCode: string | null, + toCode: string | null, +): string | null { + if (!date) return null; + if (flightNumber) return `fn:${flightNumber.toUpperCase()}@${date}`; + if (fromCode && toCode) return `rt:${fromCode.toUpperCase()}-${toCode.toUpperCase()}@${date}`; + return null; +} + +/** + * Import the given AirTrail flights into a trip as reservations (type:'flight'), + * recording the AirTrail linkage for two-way sync and broadcasting each one live. + * + * Dedup: a flight already linked to this trip is skipped ('already-imported'); a + * flight that looks like one already in the trip — e.g. the same flight another + * member already imported from their own AirTrail — is skipped ('already-in-trip'). + * The server re-fetches the flights by id with the caller's own key, so the client + * cannot inject arbitrary flight data. + */ +export async function importAirtrailFlights( + tripId: string | number, + userId: number, + flightIds: string[], + socketId: string | undefined, +): Promise { + const creds = getAirtrailCredentials(userId); + if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400); + + const wanted = new Set(flightIds.map(String)); + const selected = (await listFlights(creds)).filter(f => wanted.has(String(f.id))); + + const result: AirtrailImportResult = { imported: [], skipped: [] }; + + const linkedIds = new Set( + (db.prepare("SELECT external_id FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'").all(tripId) as { + external_id: string | null; + }[]) + .map(r => r.external_id) + .filter((v): v is string => !!v), + ); + + const existing = db + .prepare( + `SELECT r.id, r.reservation_time, r.metadata, + (SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'from' LIMIT 1) AS from_code, + (SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'to' LIMIT 1) AS to_code + FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`, + ) + .all(tripId) as ExistingFlightRow[]; + + const existingSigs = new Set(); + for (const row of existing) { + let fn: string | null = null; + try { + fn = row.metadata ? (JSON.parse(row.metadata).flight_number ?? null) : null; + } catch { + /* malformed metadata — ignore */ + } + const sig = softSignature(depDate(row.reservation_time), fn, row.from_code, row.to_code); + if (sig) existingSigs.add(sig); + } + + for (const flight of selected) { + const fid = String(flight.id); + if (linkedIds.has(fid)) { + result.skipped.push({ flightId: fid, reason: 'already-imported' }); + continue; + } + + const mapped = mapFlightToReservation(flight); + const sig = softSignature( + depDate(mapped.reservation_time), + (mapped.metadata.flight_number as string) ?? null, + mapped.endpoints.find(e => e.role === 'from')?.code ?? null, + mapped.endpoints.find(e => e.role === 'to')?.code ?? null, + ); + if (sig && existingSigs.has(sig)) { + result.skipped.push({ flightId: fid, reason: 'already-in-trip', detail: mapped.title }); + continue; + } + + try { + const { reservation } = createReservation(tripId, mapped as any); + const now = new Date().toISOString(); + db.prepare( + `UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?, + sync_enabled = 1, external_hash = ?, external_synced_at = ? WHERE id = ?`, + ).run(fid, userId, canonicalHash(flight), now, reservation.id); + + // Carry the linkage on the broadcast payload so members see the badge live. + reservation.external_source = 'airtrail'; + reservation.external_id = fid; + reservation.external_owner_user_id = userId; + reservation.sync_enabled = 1; + reservation.external_synced_at = now; + + broadcast(tripId, 'reservation:created', { reservation }, socketId); + if (sig) existingSigs.add(sig); + linkedIds.add(fid); + result.imported.push(fid); + } catch (err) { + console.error('[airtrail-import] failed to import flight', fid, err instanceof Error ? err.message : err); + result.skipped.push({ flightId: fid, reason: 'invalid', detail: err instanceof Error ? err.message : undefined }); + } + } + + return result; +} diff --git a/server/src/services/airtrail/airtrailMapper.ts b/server/src/services/airtrail/airtrailMapper.ts new file mode 100644 index 00000000..392e31e1 --- /dev/null +++ b/server/src/services/airtrail/airtrailMapper.ts @@ -0,0 +1,200 @@ +import * as crypto from 'node:crypto'; +import type { AirtrailAirport, AirtrailFlightRaw, AirtrailNamedCode } from './airtrailClient'; +import type { AirtrailFlight } from '@trek/shared'; + +/** Preferred display/lookup code for an airport. */ +function airportCode(a: AirtrailAirport | null): string | null { + return a?.iata || a?.icao || null; +} + +/** + * Airline/aircraft arrive as joined objects ({icao, iata, name, ...}); reduce + * them to a single code (ICAO preferred, matching AirTrail's save shape). + */ +function entityCode(e: AirtrailNamedCode | null | undefined): string | null { + return e?.icao || e?.iata || null; +} + +/** + * Local calendar date + clock time for an instant at a given IANA zone. + * AirTrail stores `departure`/`arrival` as instants (ISO w/ offset) plus a local + * `date`; the airport-local wall time is what TREK shows and files days by. + */ +function localParts(iso: string | null, tz: string | null): { date: string | null; time: string | null } { + if (!iso) return { date: null, time: null }; + try { + const d = new Date(iso); + if (isNaN(d.getTime())) return { date: null, time: null }; + const fmt = new Intl.DateTimeFormat('en-CA', { + timeZone: tz || 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + const parts = fmt.formatToParts(d); + const get = (t: string) => parts.find(p => p.type === t)?.value ?? ''; + const date = `${get('year')}-${get('month')}-${get('day')}`; + let hh = get('hour'); + if (hh === '24') hh = '00'; // some ICU builds emit 24:00 for midnight + const time = `${hh}:${get('minute')}`; + return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time }; + } catch { + return { date: null, time: null }; + } +} + +/** Raw AirTrail flight → the normalized shape the import picker consumes. */ +export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight { + return { + id: String(raw.id), + fromCode: airportCode(raw.from), + fromName: raw.from?.name ?? null, + toCode: airportCode(raw.to), + toName: raw.to?.name ?? null, + date: raw.date ?? null, + departure: raw.departure ?? null, + arrival: raw.arrival ?? null, + airline: entityCode(raw.airline), + flightNumber: raw.flightNumber ?? null, + aircraft: entityCode(raw.aircraft), + seatClass: (raw.seats?.find(s => s.userId) ?? raw.seats?.[0])?.seatClass ?? null, + }; +} + +export interface MappedEndpoint { + role: 'from' | 'to' | 'stop'; + sequence: number; + name: string; + code: string | null; + lat: number; + lng: number; + timezone: string | null; + local_time: string | null; + local_date: string | null; +} + +export interface MappedReservation { + title: string; + type: 'flight'; + status: 'confirmed'; + reservation_time: string | null; + reservation_end_time: string | null; + notes: string | null; + metadata: Record; + endpoints: MappedEndpoint[]; + needs_review: number; +} + +function hasCoords(a: AirtrailAirport | null): a is AirtrailAirport & { lat: number; lng: number } { + return !!a && typeof a.lat === 'number' && typeof a.lon === 'number'; +} + +/** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */ +export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation { + const dep = localParts(raw.departure, raw.from?.tz ?? null); + const arr = localParts(raw.arrival, raw.to?.tz ?? null); + + const fromCode = airportCode(raw.from); + const toCode = airportCode(raw.to); + const datePrefix = raw.date || dep.date; + const reservation_time = datePrefix ? `${datePrefix}T${dep.time ?? '00:00'}` : null; + const reservation_end_time = arr.date ? `${arr.date}T${arr.time ?? '00:00'}` : null; + + const endpoints: MappedEndpoint[] = []; + let needsReview = raw.datePrecision && raw.datePrecision !== 'day' ? 1 : 0; + + if (hasCoords(raw.from)) { + endpoints.push({ + role: 'from', + sequence: 0, + name: raw.from.name || fromCode || 'Departure', + code: fromCode, + lat: raw.from.lat, + lng: raw.from.lon, + timezone: raw.from.tz, + local_time: dep.time, + local_date: datePrefix, + }); + } else { + needsReview = 1; + } + + if (hasCoords(raw.to)) { + endpoints.push({ + role: 'to', + sequence: 1, + name: raw.to.name || toCode || 'Arrival', + code: toCode, + lat: raw.to.lat, + lng: raw.to.lon, + timezone: raw.to.tz, + local_time: arr.time, + local_date: arr.date, + }); + } else { + needsReview = 1; + } + + const seat = raw.seats?.find(s => s.userId) ?? raw.seats?.[0]; + const airlineCode = entityCode(raw.airline); + const aircraftCode = entityCode(raw.aircraft); + const metadata: Record = {}; + if (airlineCode) metadata.airline = airlineCode; + if (raw.flightNumber) metadata.flight_number = raw.flightNumber; + if (aircraftCode) metadata.aircraft = aircraftCode; + if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg; + if (raw.flightReason) metadata.flight_reason = raw.flightReason; + if (seat?.seatNumber || seat?.seatClass) metadata.seat = seat.seatNumber || seat.seatClass; + + // The flight number already carries the airline prefix (e.g. "SAS983"), so it + // makes the clearest title; fall back to the route. + const title = raw.flightNumber?.trim() || `${fromCode || '?'} → ${toCode || '?'}`; + + return { + title, + type: 'flight', + status: 'confirmed', + reservation_time, + reservation_end_time, + notes: raw.note ?? null, + metadata, + endpoints, + needs_review: needsReview, + }; +} + +/** + * Stable snapshot hash of an AirTrail flight, used by the sync engine to detect + * remote changes (AirTrail exposes no updated_at/etag) and to suppress TREK's own + * writes from re-triggering a pull. Only fields that can meaningfully change are + * included, in a fixed key order. + */ +export function canonicalHash(raw: AirtrailFlightRaw): string { + const snapshot = { + from: airportCode(raw.from), + to: airportCode(raw.to), + date: raw.date ?? null, + datePrecision: raw.datePrecision ?? 'day', + departure: raw.departure ?? null, + arrival: raw.arrival ?? null, + airline: entityCode(raw.airline), + flightNumber: raw.flightNumber ?? null, + aircraft: entityCode(raw.aircraft), + aircraftReg: raw.aircraftReg ?? null, + flightReason: raw.flightReason ?? null, + note: raw.note ?? null, + seats: (raw.seats ?? []) + .map(s => ({ + userId: s.userId ?? null, + guestName: s.guestName ?? null, + seat: s.seat ?? null, + seatNumber: s.seatNumber ?? null, + seatClass: s.seatClass ?? null, + })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + }; + return crypto.createHash('sha256').update(JSON.stringify(snapshot)).digest('hex'); +} diff --git a/server/src/services/airtrail/airtrailService.ts b/server/src/services/airtrail/airtrailService.ts new file mode 100644 index 00000000..f4ed6264 --- /dev/null +++ b/server/src/services/airtrail/airtrailService.ts @@ -0,0 +1,153 @@ +import type { AirtrailFlight } from '@trek/shared'; +import { db } from '../../db/database'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto'; +import { checkSsrf } from '../../utils/ssrfGuard'; +import { writeAudit } from '../auditLog'; +import { AirtrailAuthError, AirtrailCreds, AirtrailRequestError, listFlights } from './airtrailClient'; +import { normalizeFlight } from './airtrailMapper'; + +const KEY_MASK = '••••••••'; + +interface UserConnRow { + airtrail_url?: string | null; + airtrail_api_key?: string | null; + airtrail_allow_insecure_tls?: number | null; +} + +function readRow(userId: number): UserConnRow | undefined { + return db + .prepare('SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls FROM users WHERE id = ?') + .get(userId) as UserConnRow | undefined; +} + +/** Decrypted creds for outbound calls, or null when the user has no connection. */ +export function getAirtrailCredentials(userId: number): AirtrailCreds | null { + const row = readRow(userId); + if (!row?.airtrail_url || !row?.airtrail_api_key) return null; + const apiKey = decrypt_api_key(row.airtrail_api_key); + if (!apiKey) return null; + return { + baseUrl: row.airtrail_url, + apiKey, + allowInsecureTls: !!row.airtrail_allow_insecure_tls, + }; +} + +/** Settings as shown in the UI — the key is never echoed, only masked. */ +export function getConnectionSettings(userId: number) { + const row = readRow(userId); + return { + url: row?.airtrail_url || '', + apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '', + allowInsecureTls: !!row?.airtrail_allow_insecure_tls, + connected: !!(row?.airtrail_url && row?.airtrail_api_key), + }; +} + +export async function saveSettings( + userId: number, + url: string | undefined, + apiKey: string | undefined, + allowInsecureTls: boolean, + clientIp: string | null, +): Promise<{ success: boolean; warning?: string; error?: string }> { + const trimmedUrl = (url || '').trim(); + let warning: string | undefined; + + if (trimmedUrl) { + const ssrf = await checkSsrf(trimmedUrl); + // Reject only genuinely unusable URLs (malformed, unresolvable, non-http, + // loopback). Private/LAN instances are the common self-hosted case, so we + // persist them with a warning rather than blocking — the outbound calls + // still need ALLOW_INTERNAL_NETWORK=true to actually reach them. + if (!ssrf.allowed && !ssrf.isPrivate) { + return { success: false, error: ssrf.error ?? 'Invalid AirTrail URL' }; + } + if (ssrf.isPrivate) { + writeAudit({ + userId, + action: 'airtrail.private_ip_configured', + ip: clientIp, + details: { airtrail_url: trimmedUrl, resolved_ip: ssrf.resolvedIp }, + }); + warning = `AirTrail URL resolves to a private IP (${ssrf.resolvedIp}). Make sure this is intentional — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.`; + } + } + + // Only overwrite the stored key when a genuinely new value is supplied; + // a blank field or the mask means "keep the existing key". + const provided = (apiKey || '').trim(); + const newKey = provided && provided !== KEY_MASK ? maybe_encrypt_api_key(provided) : undefined; + + if (newKey !== undefined) { + db.prepare( + 'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?', + ).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId); + } else { + db.prepare( + 'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?', + ).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId); + // Clearing the URL with no key left makes the connection meaningless — drop the key too. + if (!trimmedUrl) { + db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId); + } + } + + return { success: true, warning }; +} + +async function probe(creds: AirtrailCreds): Promise<{ connected: boolean; flightCount?: number; error?: string }> { + try { + const flights = await listFlights(creds); + return { connected: true, flightCount: flights.length }; + } catch (err: unknown) { + if (err instanceof AirtrailAuthError) return { connected: false, error: 'Invalid API key' }; + return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + } +} + +/** Live check using the stored connection. */ +export async function getConnectionStatus( + userId: number, +): Promise<{ connected: boolean; flightCount?: number; error?: string }> { + const creds = getAirtrailCredentials(userId); + if (!creds) return { connected: false, error: 'Not configured' }; + return probe(creds); +} + +/** + * "Test connection" from the settings form. Uses the typed URL/key when given; + * falls back to the stored key when the key field still shows the mask. + */ +export async function testConnection( + userId: number, + url: string | undefined, + apiKey: string | undefined, + allowInsecureTls: boolean, +): Promise<{ connected: boolean; flightCount?: number; error?: string }> { + const trimmedUrl = (url || '').trim(); + const provided = (apiKey || '').trim(); + + const stored = getAirtrailCredentials(userId); + const effectiveUrl = trimmedUrl || stored?.baseUrl; + const effectiveKey = provided && provided !== KEY_MASK ? provided : stored?.apiKey; + + if (!effectiveUrl || !effectiveKey) { + return { connected: false, error: 'URL and API key required' }; + } + + const ssrf = await checkSsrf(effectiveUrl); + if (!ssrf.allowed && !ssrf.isPrivate) { + return { connected: false, error: ssrf.error ?? 'Invalid AirTrail URL' }; + } + + return probe({ baseUrl: effectiveUrl, apiKey: effectiveKey, allowInsecureTls }); +} + +/** The user's AirTrail flights, normalized for the import picker. */ +export async function getFlightsForPicker(userId: number): Promise { + const creds = getAirtrailCredentials(userId); + if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400); + const raw = await listFlights(creds); + return raw.map(normalizeFlight); +} diff --git a/server/src/services/airtrail/airtrailSync.ts b/server/src/services/airtrail/airtrailSync.ts new file mode 100644 index 00000000..52eb6c2f --- /dev/null +++ b/server/src/services/airtrail/airtrailSync.ts @@ -0,0 +1,264 @@ +import { db } from '../../db/database'; +import { logError, logInfo } from '../auditLog'; +import { broadcast } from '../../websocket'; +import { isAddonEnabled } from '../adminService'; +import { ADDON_IDS } from '../../addons'; +import { getReservation, getReservationWithJoins, updateReservation } from '../reservationService'; +import { getAirtrailCredentials } from './airtrailService'; +import { + AirtrailAuthError, + AirtrailCreds, + AirtrailFlightRaw, + AirtrailSavePayload, + getFlight, + listFlights, + saveFlight, +} from './airtrailClient'; +import { canonicalHash, mapFlightToReservation } from './airtrailMapper'; + +/** Global on/off: the addon must be enabled and sync not explicitly turned off. */ +export function syncGloballyEnabled(): boolean { + if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) return false; + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'airtrail_sync_enabled'").get() as + | { value: string } + | undefined; + return row?.value !== 'false'; +} + +function broadcastUpdated(tripId: number, reservationId: number): void { + try { + const reservation = getReservationWithJoins(reservationId); + if (reservation) broadcast(tripId, 'reservation:updated', { reservation }); + } catch { + /* broadcast failure is non-fatal */ + } +} + +function detach(tripId: number, reservationId: number): void { + db.prepare('UPDATE reservations SET sync_enabled = 0 WHERE id = ?').run(reservationId); + broadcastUpdated(tripId, reservationId); +} + +// ── AirTrail → TREK (poll) ─────────────────────────────────────────────────── + +/** + * Reconcile one owner's linked reservations against their current AirTrail + * flights: apply field changes (detected by snapshot hash, since AirTrail has no + * updated_at) and, when a flight is gone from AirTrail, keep the TREK row but + * stop syncing it. Only already-imported flights are touched — new AirTrail + * flights are never auto-added to a trip. Returns how many rows changed. + */ +async function syncOwner(uid: number): Promise { + const creds = getAirtrailCredentials(uid); + if (!creds) return 0; // owner disconnected — leave their linked rows as-is + + let flights: AirtrailFlightRaw[]; + try { + flights = await listFlights(creds); + } catch (err) { + if (err instanceof AirtrailAuthError) logError(`AirTrail sync: invalid API key for user ${uid}`); + return 0; + } + const byId = new Map(flights.map(f => [String(f.id), f])); + + const linked = db + .prepare( + "SELECT id, trip_id, external_id, external_hash FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id = ?", + ) + .all(uid) as { id: number; trip_id: number; external_id: string; external_hash: string | null }[]; + + let changed = 0; + for (const row of linked) { + const flight = byId.get(String(row.external_id)); + if (!flight) { + detach(row.trip_id, row.id); // deleted in AirTrail → keep row, stop syncing + changed++; + continue; + } + + const hash = canonicalHash(flight); + if (hash === row.external_hash) continue; + + const current = getReservation(row.id, row.trip_id); + if (!current) continue; + try { + updateReservation(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any); + db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run( + hash, + new Date().toISOString(), + row.id, + ); + broadcastUpdated(row.trip_id, row.id); + changed++; + } catch (err) { + logError(`AirTrail sync: failed to update reservation ${row.id}: ${err instanceof Error ? err.message : err}`); + } + } + return changed; +} + +let running = false; + +/** Background poll across every connected owner (scheduler). */ +export async function runAirtrailSync(): Promise { + if (running) return; + if (!syncGloballyEnabled()) return; + running = true; + let changed = 0; + try { + const owners = db + .prepare( + "SELECT DISTINCT external_owner_user_id AS uid FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id IS NOT NULL", + ) + .all() as { uid: number }[]; + for (const { uid } of owners) changed += await syncOwner(uid); + if (changed > 0) logInfo(`AirTrail sync: applied ${changed} change(s)`); + } catch (err) { + logError(`AirTrail sync failed: ${err instanceof Error ? err.message : err}`); + } finally { + running = false; + } +} + +/** + * On-demand sync of just this user's linked flights — called when the user opens + * a trip so AirTrail-side edits show up immediately instead of waiting for the + * background poll. + */ +export async function runAirtrailSyncForUser(userId: number): Promise<{ changed: number }> { + if (!syncGloballyEnabled()) return { changed: 0 }; + try { + return { changed: await syncOwner(userId) }; + } catch (err) { + logError(`AirTrail sync (user ${userId}) failed: ${err instanceof Error ? err.message : err}`); + return { changed: 0 }; + } +} + +// ── TREK → AirTrail (push) ─────────────────────────────────────────────────── + +function splitLocal(dt: string | null | undefined): { date: string | null; time: string | null } { + if (!dt) return { date: null, time: null }; + const date = dt.slice(0, 10); + const m = dt.slice(10).match(/(\d{2}:\d{2})/); + return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time: m ? m[1] : null }; +} + +function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null { + let meta: Record = {}; + try { + meta = reservation.metadata ? JSON.parse(reservation.metadata) : {}; + } catch { + meta = {}; + } + const endpoints: any[] = reservation.endpoints || []; + const fromEp = endpoints.find(e => e.role === 'from'); + const toEp = endpoints.find(e => e.role === 'to'); + const fromCode = fromEp?.code || existing.from?.iata || existing.from?.icao || null; + const toCode = toEp?.code || existing.to?.iata || existing.to?.icao || null; + if (!fromCode || !toCode) return null; + + const dep = splitLocal(reservation.reservation_time); + const arr = splitLocal(reservation.reservation_end_time); + if (!dep.date) return null; + + // Preserve the existing seat manifest (an update replaces all seats); fall back + // to the key-owner placeholder so AirTrail attributes it to the connecting user. + const seats = (existing.seats ?? []).map(s => ({ + userId: s.userId, + guestName: s.guestName, + seat: s.seat, + seatNumber: s.seatNumber, + seatClass: s.seatClass, + })); + if (seats.length === 0) { + seats.push({ userId: '', guestName: null, seat: null, seatNumber: null, seatClass: null }); + } + + // Push the seat the user set in TREK onto their own AirTrail seat (the one with + // a userId), leaving any co-passenger seats untouched. + const seatNumber = typeof meta.seat === 'string' && meta.seat.trim() ? meta.seat.trim() : null; + if (seatNumber) { + const ownSeat = seats.find(s => s.userId) ?? seats[0]; + if (ownSeat) ownSeat.seatNumber = seatNumber; + } + + return { + id: Number(reservation.external_id), + from: fromCode, + to: toCode, + departure: dep.date, + departureTime: dep.time, + arrival: arr.date, + arrivalTime: arr.time, + airline: meta.airline ?? null, + flightNumber: meta.flight_number ?? null, + aircraft: meta.aircraft ?? null, + aircraftReg: meta.aircraft_reg ?? null, + flightReason: meta.flight_reason ?? null, + note: reservation.notes ?? null, + seats, + }; +} + +/** + * Push a locally-edited linked reservation back to AirTrail using the importer's + * (owner's) credentials — even if a different member made the edit. If the owner + * is gone or the flight no longer exists in AirTrail, the link is detached so the + * next pull's AirTrail-wins policy can't silently revert the local edit. + */ +export async function pushReservationToAirtrail(reservationId: number, tripId: number): Promise { + if (!syncGloballyEnabled()) return; + + const row = db + .prepare( + "SELECT id, trip_id, external_id, external_owner_user_id, sync_enabled FROM reservations WHERE id = ? AND external_source = 'airtrail'", + ) + .get(reservationId) as + | { id: number; trip_id: number; external_id: string; external_owner_user_id: number | null; sync_enabled: number } + | undefined; + if (!row || !row.sync_enabled) return; + + const creds: AirtrailCreds | null = row.external_owner_user_id + ? getAirtrailCredentials(row.external_owner_user_id) + : null; + if (!creds) { + detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing + return; + } + + let existing: AirtrailFlightRaw | null; + try { + existing = await getFlight(creds, Number(row.external_id)); + } catch (err) { + if (err instanceof AirtrailAuthError) detach(tripId, row.id); + else logError(`AirTrail push: get failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`); + return; + } + if (!existing) { + detach(tripId, row.id); // gone in AirTrail → treat like a remote delete + return; + } + + const reservation = getReservationWithJoins(row.id); + if (!reservation) return; + + const payload = buildSavePayload(reservation, existing); + if (!payload) return; + + try { + await saveFlight(creds, payload); + // Self-write suppression: re-read the saved flight and store its hash so the + // next poll doesn't treat our own write as an inbound change. + const saved = await getFlight(creds, Number(row.external_id)); + if (saved) { + db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run( + canonicalHash(saved), + new Date().toISOString(), + row.id, + ); + } + } catch (err) { + logError(`AirTrail push failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`); + } +} diff --git a/server/tests/unit/db/atlas-region-crosswalk.test.ts b/server/tests/unit/db/atlas-region-crosswalk.test.ts index 56fd1bc4..c53a7fa5 100644 --- a/server/tests/unit/db/atlas-region-crosswalk.test.ts +++ b/server/tests/unit/db/atlas-region-crosswalk.test.ts @@ -30,10 +30,14 @@ function mark(db: Database.Database, userId: number, code: string, name: string, ).run(userId, code, name, country); } -// Rewind one migration and re-run so only the reconciliation (the last migration) executes. +// The visited_regions reconciliation (#1119) is pinned at schema version 135. +// Migrations added afterwards are appended AFTER it (append-only), so it is no +// longer the last migration. Rewind to just before the reconciliation and +// re-run: the later migrations are idempotent, so only the reconciliation has +// any effect on the seeded rows here. +const RECONCILIATION_VERSION = 135; function rerunLastMigration(db: Database.Database) { - const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version; - db.prepare('UPDATE schema_version SET version = ?').run(version - 1); + db.prepare('UPDATE schema_version SET version = ?').run(RECONCILIATION_VERSION - 1); runMigrations(db); } diff --git a/server/tests/unit/services/airtrailMapper.test.ts b/server/tests/unit/services/airtrailMapper.test.ts new file mode 100644 index 00000000..dd808141 --- /dev/null +++ b/server/tests/unit/services/airtrailMapper.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; +import { canonicalHash, mapFlightToReservation, normalizeFlight } from '../../../src/services/airtrail/airtrailMapper'; +import type { AirtrailFlightRaw } from '../../../src/services/airtrail/airtrailClient'; + +function airport(over: Partial = {}): NonNullable { + return { + id: 1, + icao: 'KJFK', + iata: 'JFK', + name: 'John F. Kennedy Intl.', + lat: 40.6413, + lon: -73.7781, + tz: 'America/New_York', + country: 'US', + ...over, + }; +} + +function flight(over: Partial = {}): AirtrailFlightRaw { + return { + id: 42, + from: airport(), + to: airport({ id: 2, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', lat: 51.4706, lon: -0.4619, tz: 'Europe/London' }), + date: '2021-09-01', + datePrecision: 'day', + departure: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4) + arrival: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1) + airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' }, + flightNumber: 'BA178', + aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' }, + aircraftReg: 'G-VIIL', + flightReason: 'leisure', + note: 'window seat', + seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }], + ...over, + }; +} + +describe('airtrailMapper.normalizeFlight', () => { + it('prefers IATA codes and exposes the picker fields', () => { + const n = normalizeFlight(flight()); + expect(n).toMatchObject({ + id: '42', + fromCode: 'JFK', + toCode: 'LHR', + date: '2021-09-01', + airline: 'BAW', + flightNumber: 'BA178', + seatClass: 'economy', + }); + }); + + it('falls back to ICAO when IATA is missing and tolerates null airports', () => { + const n = normalizeFlight(flight({ from: airport({ iata: null }), to: null })); + expect(n.fromCode).toBe('KJFK'); + expect(n.toCode).toBeNull(); + expect(n.toName).toBeNull(); + }); +}); + +describe('airtrailMapper.mapFlightToReservation', () => { + it('composes airport-local times from the instant + airport tz', () => { + const m = mapFlightToReservation(flight()); + // 23:00 UTC at JFK in September is 19:00 EDT; date stays the AirTrail local date. + expect(m.reservation_time).toBe('2021-09-01T19:00'); + // 07:00 UTC at LHR in September is 08:00 BST. + expect(m.reservation_end_time).toBe('2021-09-02T08:00'); + }); + + it('builds two endpoints with codes, coords and timezones', () => { + const m = mapFlightToReservation(flight()); + expect(m.endpoints).toHaveLength(2); + expect(m.endpoints[0]).toMatchObject({ role: 'from', code: 'JFK', lat: 40.6413, timezone: 'America/New_York', local_date: '2021-09-01', local_time: '19:00' }); + expect(m.endpoints[1]).toMatchObject({ role: 'to', code: 'LHR', timezone: 'Europe/London', local_time: '08:00' }); + expect(m.needs_review).toBe(0); + }); + + it('titles from the flight number, else the route', () => { + expect(mapFlightToReservation(flight()).title).toBe('BA178'); + expect(mapFlightToReservation(flight({ airline: null, flightNumber: null })).title).toBe('JFK → LHR'); + }); + + it('carries flight metadata', () => { + const m = mapFlightToReservation(flight()); + expect(m.metadata).toMatchObject({ airline: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' }); + expect(m.type).toBe('flight'); + expect(m.status).toBe('confirmed'); + expect(m.notes).toBe('window seat'); + }); + + it('flags needs_review for a non-day date precision', () => { + expect(mapFlightToReservation(flight({ datePrecision: 'month' })).needs_review).toBe(1); + }); + + it('flags needs_review and drops the endpoint when an airport has no coordinates', () => { + const m = mapFlightToReservation(flight({ from: airport({ lat: null, lon: null }) })); + expect(m.needs_review).toBe(1); + expect(m.endpoints.find(e => e.role === 'from')).toBeUndefined(); + expect(m.endpoints.find(e => e.role === 'to')).toBeDefined(); + }); + + it('leaves the end time null for a partial flight with no arrival', () => { + const m = mapFlightToReservation(flight({ arrival: null })); + expect(m.reservation_end_time).toBeNull(); + expect(m.reservation_time).toBe('2021-09-01T19:00'); + }); +}); + +describe('airtrailMapper.canonicalHash', () => { + it('is stable for the same flight', () => { + expect(canonicalHash(flight())).toBe(canonicalHash(flight())); + }); + + it('changes when a meaningful field changes', () => { + expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ flightNumber: 'BA179' }))); + expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ note: 'aisle seat' }))); + }); + + it('is independent of seat ordering', () => { + const a = flight({ + seats: [ + { userId: 'u1', guestName: null, seat: null, seatNumber: '1A', seatClass: 'economy' }, + { userId: 'u2', guestName: null, seat: null, seatNumber: '1B', seatClass: 'economy' }, + ], + }); + const b = flight({ + seats: [ + { userId: 'u2', guestName: null, seat: null, seatNumber: '1B', seatClass: 'economy' }, + { userId: 'u1', guestName: null, seat: null, seatNumber: '1A', seatClass: 'economy' }, + ], + }); + expect(canonicalHash(a)).toBe(canonicalHash(b)); + }); +}); diff --git a/shared/src/airtrail/airtrail.schema.ts b/shared/src/airtrail/airtrail.schema.ts new file mode 100644 index 00000000..9d718974 --- /dev/null +++ b/shared/src/airtrail/airtrail.schema.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +/** + * AirTrail integration contracts (#214). + * + * AirTrail is a self-hosted flight tracker (github.com/johanohly/AirTrail). + * The connection is per-user (Settings → Integrations); the global on/off is the + * `airtrail` addon. Each user stores their instance URL + a personal Bearer API + * key, which only ever exposes that user's own flights. + */ + +// ── Per-user connection ────────────────────────────────────────────────────── + +/** Placeholder the server returns instead of the real key once one is stored. */ +export const AIRTRAIL_KEY_MASK = '••••••••'; + +export const airtrailSettingsSchema = z.object({ + /** Instance origin, e.g. https://flights.example.com — TREK appends /api itself. */ + url: z.string().trim().max(2048), + /** Bearer API key. Omitted / blank / the mask keeps the stored key unchanged. */ + apiKey: z.string().max(512).optional(), + /** Allow self-signed TLS certs (common on LAN instances). */ + allowInsecureTls: z.boolean().optional().default(false), +}); +export type AirtrailSettings = z.infer; + +export const airtrailConnectionSchema = z.object({ + url: z.string(), + apiKeyMasked: z.string(), + allowInsecureTls: z.boolean(), + connected: z.boolean(), +}); +export type AirtrailConnection = z.infer; + +export const airtrailStatusSchema = z.object({ + connected: z.boolean(), + flightCount: z.number().optional(), + error: z.string().optional(), +}); +export type AirtrailStatus = z.infer; + +// ── Flight list (picker) ───────────────────────────────────────────────────── + +/** A normalized AirTrail flight as surfaced to the import picker. */ +export const airtrailFlightSchema = z.object({ + id: z.string(), + fromCode: z.string().nullable(), + fromName: z.string().nullable(), + toCode: z.string().nullable(), + toName: z.string().nullable(), + date: z.string().nullable(), + departure: z.string().nullable(), + arrival: z.string().nullable(), + airline: z.string().nullable(), + flightNumber: z.string().nullable(), + aircraft: z.string().nullable(), + seatClass: z.string().nullable(), +}); +export type AirtrailFlight = z.infer; + +// ── Import ─────────────────────────────────────────────────────────────────── + +export const airtrailImportSchema = z.object({ + flightIds: z.array(z.string()).min(1, 'Select at least one flight'), +}); +export type AirtrailImport = z.infer; + +/** Per-flight outcome of an import (so the picker can show what was skipped). */ +export const airtrailImportResultSchema = z.object({ + imported: z.array(z.string()), + skipped: z.array( + z.object({ + flightId: z.string(), + reason: z.enum(['already-imported', 'already-in-trip', 'invalid']), + detail: z.string().optional(), + }), + ), +}); +export type AirtrailImportResult = z.infer; diff --git a/shared/src/i18n/ar/reservations.ts b/shared/src/i18n/ar/reservations.ts index 04e9f3db..779cb8f1 100644 --- a/shared/src/i18n/ar/reservations.ts +++ b/shared/src/i18n/ar/reservations.ts @@ -140,5 +140,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'استيراد الحجوزات غير متاح على هذا الخادم.', 'reservations.import.unsupportedFormat': 'صيغة ملف غير مدعومة. استخدم EML أو PDF أو PKPass أو HTML أو TXT.', 'reservations.import.fileTooLarge': 'الملف "{name}" يتجاوز حد 10 ميغابايت.', + 'reservations.airtrail.title': 'استيراد من AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'متزامن من AirTrail — تبقى التعديلات متزامنة في الاتجاهين.', + 'reservations.airtrail.notSynced': 'غير متزامن', + 'reservations.airtrail.notSyncedHint': 'تمت إزالة هذه الرحلة في AirTrail ولم تعد متزامنة.', + 'reservations.airtrail.loadError': 'تعذّر تحميل رحلاتك من AirTrail.', + 'reservations.airtrail.imported': 'تم استيراد {count} رحلة/رحلات', + 'reservations.airtrail.skippedDuplicate': '{count} موجودة بالفعل في هذه الرحلة، تم تخطّيها', + 'reservations.airtrail.nothingImported': 'لا شيء لاستيراده.', + 'reservations.airtrail.importError': 'فشل الاستيراد. يُرجى المحاولة مرة أخرى.', + 'reservations.airtrail.undo': 'استيراد من AirTrail', + 'reservations.airtrail.alreadyImported': 'مُستورَد', + 'reservations.airtrail.duringTrip': 'خلال هذه الرحلة', + 'reservations.airtrail.otherFlights': 'رحلات أخرى', + 'reservations.airtrail.empty': 'لم يتم العثور على أي رحلات في حساب AirTrail الخاص بك.', + 'reservations.airtrail.importCta': 'استيراد {count}', }; export default reservations; diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 343dda45..a26ea57a 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -319,6 +319,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'لم يُستخدم قط', 'settings.mapPoiPill': 'استكشاف الأماكن على الخريطة', 'settings.mapPoiPillHint': 'أظهر شريط فئات على خريطة الرحلة للعثور على المطاعم والفنادق والمزيد القريبة من OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'اربط نسخة AirTrail المُستضافة ذاتيًا لاستيراد الرحلات ومزامنتها. أنشئ مفتاح API في AirTrail ضمن الإعدادات ← الأمان.', + 'settings.airtrail.url': 'رابط النسخة', + 'settings.airtrail.apiKey': 'مفتاح API', + 'settings.airtrail.apiKeyPlaceholder': 'مفتاح API من نوع Bearer', + 'settings.airtrail.apiKeyHint': 'يُنشأ في AirTrail ضمن الإعدادات ← الأمان. يُخزَّن مشفّرًا.', + 'settings.airtrail.allowInsecureTls': 'السماح بالشهادات الموقّعة ذاتيًا', + 'settings.airtrail.allowInsecureTlsHint': 'فعّل هذا فقط لنسخة موثوقة على شبكتك الخاصة.', + 'settings.airtrail.connected': 'متصل', + 'settings.airtrail.notConnected': 'غير متصل', + 'settings.airtrail.toast.saved': 'تم حفظ اتصال AirTrail', + 'settings.airtrail.toast.saveError': 'تعذّر حفظ الاتصال', + 'settings.airtrail.test.button': 'اختبار الاتصال', + 'settings.airtrail.test.success': 'متصل — تم العثور على {count} رحلة/رحلات', + 'settings.airtrail.test.failed': 'فشل الاتصال', }; export default settings; diff --git a/shared/src/i18n/br/reservations.ts b/shared/src/i18n/br/reservations.ts index f26c3108..f64891a8 100644 --- a/shared/src/i18n/br/reservations.ts +++ b/shared/src/i18n/br/reservations.ts @@ -141,5 +141,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'A importação de reservas não está disponível neste servidor.', 'reservations.import.unsupportedFormat': 'Formato de arquivo não suportado. Use EML, PDF, PKPass, HTML ou TXT.', 'reservations.import.fileTooLarge': 'O arquivo "{name}" excede o limite de 10 MB.', + 'reservations.airtrail.title': 'Importar do AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Sincronizado do AirTrail — as edições permanecem em sincronia nos dois sentidos.', + 'reservations.airtrail.notSynced': 'Não sincronizado', + 'reservations.airtrail.notSyncedHint': 'Este voo foi removido no AirTrail e não sincroniza mais.', + 'reservations.airtrail.loadError': 'Não foi possível carregar seus voos do AirTrail.', + 'reservations.airtrail.imported': '{count} voo(s) importado(s)', + 'reservations.airtrail.skippedDuplicate': '{count} já nesta viagem, ignorado(s)', + 'reservations.airtrail.nothingImported': 'Nada para importar.', + 'reservations.airtrail.importError': 'Falha na importação. Tente novamente.', + 'reservations.airtrail.undo': 'Importar do AirTrail', + 'reservations.airtrail.alreadyImported': 'Importado', + 'reservations.airtrail.duringTrip': 'Durante esta viagem', + 'reservations.airtrail.otherFlights': 'Outros voos', + 'reservations.airtrail.empty': 'Nenhum voo encontrado na sua conta do AirTrail.', + 'reservations.airtrail.importCta': 'Importar {count}', }; export default reservations; diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 14ad19ee..5aaf826c 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -325,6 +325,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Nunca usada', 'settings.mapPoiPill': 'Explorar lugares no mapa', 'settings.mapPoiPillHint': 'Mostrar uma etiqueta de categoria no mapa da viagem para encontrar restaurantes, hotéis e mais por perto a partir do OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Conecte seu AirTrail auto-hospedado para importar e sincronizar voos. Crie uma chave de API no AirTrail em Configurações → Segurança.', + 'settings.airtrail.url': 'URL da instância', + 'settings.airtrail.apiKey': 'Chave de API', + 'settings.airtrail.apiKeyPlaceholder': 'Chave de API Bearer', + 'settings.airtrail.apiKeyHint': 'Gerada no AirTrail em Configurações → Segurança. Armazenada de forma criptografada.', + 'settings.airtrail.allowInsecureTls': 'Permitir certificados autoassinados', + 'settings.airtrail.allowInsecureTlsHint': 'Ative apenas para uma instância confiável na sua própria rede.', + 'settings.airtrail.connected': 'Conectado', + 'settings.airtrail.notConnected': 'Não conectado', + 'settings.airtrail.toast.saved': 'Conexão com o AirTrail salva', + 'settings.airtrail.toast.saveError': 'Não foi possível salvar a conexão', + 'settings.airtrail.test.button': 'Testar conexão', + 'settings.airtrail.test.success': 'Conectado — {count} voo(s) encontrado(s)', + 'settings.airtrail.test.failed': 'Falha na conexão', }; export default settings; diff --git a/shared/src/i18n/cs/reservations.ts b/shared/src/i18n/cs/reservations.ts index 0b43d17b..49737aef 100644 --- a/shared/src/i18n/cs/reservations.ts +++ b/shared/src/i18n/cs/reservations.ts @@ -140,5 +140,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'Import rezervací není na tomto serveru k dispozici.', 'reservations.import.unsupportedFormat': 'Nepodporovaný formát souboru. Použijte EML, PDF, PKPass, HTML nebo TXT.', 'reservations.import.fileTooLarge': 'Soubor „{name}" překračuje limit 10 MB.', + 'reservations.airtrail.title': 'Import z AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Synchronizováno z AirTrail – úpravy zůstávají synchronní v obou směrech.', + 'reservations.airtrail.notSynced': 'Nesynchronizováno', + 'reservations.airtrail.notSyncedHint': 'Tento let byl v AirTrail odstraněn a již se nesynchronizuje.', + 'reservations.airtrail.loadError': 'Vaše lety z AirTrail se nepodařilo načíst.', + 'reservations.airtrail.imported': 'Importováno letů: {count}', + 'reservations.airtrail.skippedDuplicate': 'Již v tomto výletu: {count}, přeskočeno', + 'reservations.airtrail.nothingImported': 'Není co importovat.', + 'reservations.airtrail.importError': 'Import selhal. Zkuste to prosím znovu.', + 'reservations.airtrail.undo': 'Import z AirTrail', + 'reservations.airtrail.alreadyImported': 'Importováno', + 'reservations.airtrail.duringTrip': 'Během tohoto výletu', + 'reservations.airtrail.otherFlights': 'Ostatní lety', + 'reservations.airtrail.empty': 'Ve vašem účtu AirTrail nebyly nalezeny žádné lety.', + 'reservations.airtrail.importCta': 'Importovat {count}', }; export default reservations; diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index bbf42df9..6cd6b27e 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -326,6 +326,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Nikdy nepoužito', 'settings.mapPoiPill': 'Objevovat místa na mapě', 'settings.mapPoiPillHint': 'Zobrazit na mapě výletu kategorie pro hledání restaurací, hotelů a dalšího v okolí z OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Připojte svou vlastní instanci AirTrail pro import a synchronizaci letů. Vytvořte API klíč v AirTrail v Nastavení → Zabezpečení.', + 'settings.airtrail.url': 'URL instance', + 'settings.airtrail.apiKey': 'API klíč', + 'settings.airtrail.apiKeyPlaceholder': 'API klíč Bearer', + 'settings.airtrail.apiKeyHint': 'Vygenerován v AirTrail v Nastavení → Zabezpečení. Uložen šifrovaně.', + 'settings.airtrail.allowInsecureTls': 'Povolit certifikáty podepsané sebou samým', + 'settings.airtrail.allowInsecureTlsHint': 'Povolte pouze pro důvěryhodnou instanci ve vlastní síti.', + 'settings.airtrail.connected': 'Připojeno', + 'settings.airtrail.notConnected': 'Nepřipojeno', + 'settings.airtrail.toast.saved': 'Připojení k AirTrail uloženo', + 'settings.airtrail.toast.saveError': 'Připojení se nepodařilo uložit', + 'settings.airtrail.test.button': 'Otestovat připojení', + 'settings.airtrail.test.success': 'Připojeno – nalezeno letů: {count}', + 'settings.airtrail.test.failed': 'Připojení selhalo', }; export default settings; diff --git a/shared/src/i18n/de/reservations.ts b/shared/src/i18n/de/reservations.ts index 13bb6483..499d0cc4 100644 --- a/shared/src/i18n/de/reservations.ts +++ b/shared/src/i18n/de/reservations.ts @@ -142,5 +142,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'Buchungsimport ist auf diesem Server nicht verfügbar.', 'reservations.import.unsupportedFormat': 'Nicht unterstütztes Dateiformat. Verwenden Sie EML, PDF, PKPass, HTML oder TXT.', 'reservations.import.fileTooLarge': 'Datei „{name}" überschreitet das 10-MB-Limit.', + 'reservations.airtrail.title': 'Aus AirTrail importieren', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Aus AirTrail synchronisiert — Änderungen bleiben in beide Richtungen synchron.', + 'reservations.airtrail.notSynced': 'Nicht synchronisiert', + 'reservations.airtrail.notSyncedHint': 'Dieser Flug wurde in AirTrail gelöscht und wird nicht mehr synchronisiert.', + 'reservations.airtrail.loadError': 'Ihre AirTrail-Flüge konnten nicht geladen werden.', + 'reservations.airtrail.imported': '{count} Flug/Flüge importiert', + 'reservations.airtrail.skippedDuplicate': '{count} bereits in dieser Reise, übersprungen', + 'reservations.airtrail.nothingImported': 'Nichts zu importieren.', + 'reservations.airtrail.importError': 'Import fehlgeschlagen. Bitte erneut versuchen.', + 'reservations.airtrail.undo': 'Aus AirTrail importieren', + 'reservations.airtrail.alreadyImported': 'Importiert', + 'reservations.airtrail.duringTrip': 'Während dieser Reise', + 'reservations.airtrail.otherFlights': 'Weitere Flüge', + 'reservations.airtrail.empty': 'Keine Flüge in Ihrem AirTrail-Konto gefunden.', + 'reservations.airtrail.importCta': '{count} importieren', }; export default reservations; diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index e3fe4f23..e19170dc 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -329,6 +329,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Noch nie verwendet', 'settings.mapPoiPill': 'Orte auf der Karte entdecken', 'settings.mapPoiPillHint': 'Zeigt auf der Reisekarte eine Kategorie-Pille an, um Restaurants, Hotels und mehr aus OpenStreetMap in der Nähe zu finden.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Verbinden Sie Ihr selbst gehostetes AirTrail, um Flüge zu importieren und zu synchronisieren. Erstellen Sie in AirTrail unter Einstellungen → Sicherheit einen API-Schlüssel.', + 'settings.airtrail.url': 'Instanz-URL', + 'settings.airtrail.apiKey': 'API-Schlüssel', + 'settings.airtrail.apiKeyPlaceholder': 'Bearer-API-Schlüssel', + 'settings.airtrail.apiKeyHint': 'Wird in AirTrail unter Einstellungen → Sicherheit erstellt. Verschlüsselt gespeichert.', + 'settings.airtrail.allowInsecureTls': 'Selbstsignierte Zertifikate erlauben', + 'settings.airtrail.allowInsecureTlsHint': 'Nur für eine vertrauenswürdige Instanz im eigenen Netzwerk aktivieren.', + 'settings.airtrail.connected': 'Verbunden', + 'settings.airtrail.notConnected': 'Nicht verbunden', + 'settings.airtrail.toast.saved': 'AirTrail-Verbindung gespeichert', + 'settings.airtrail.toast.saveError': 'Verbindung konnte nicht gespeichert werden', + 'settings.airtrail.test.button': 'Verbindung testen', + 'settings.airtrail.test.success': 'Verbunden — {count} Flug/Flüge gefunden', + 'settings.airtrail.test.failed': 'Verbindung fehlgeschlagen', }; export default settings; diff --git a/shared/src/i18n/en/reservations.ts b/shared/src/i18n/en/reservations.ts index 87ae0ee7..aaa36236 100644 --- a/shared/src/i18n/en/reservations.ts +++ b/shared/src/i18n/en/reservations.ts @@ -141,5 +141,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'Booking import is not available on this server.', 'reservations.import.unsupportedFormat': 'Unsupported file format. Use EML, PDF, PKPass, HTML, or TXT.', 'reservations.import.fileTooLarge': 'File "{name}" exceeds 10 MB limit.', + 'reservations.airtrail.title': 'Import from AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Synced from AirTrail — edits stay in sync both ways.', + 'reservations.airtrail.notSynced': 'Not synced', + 'reservations.airtrail.notSyncedHint': 'This flight was removed in AirTrail and no longer syncs.', + 'reservations.airtrail.loadError': 'Could not load your AirTrail flights.', + 'reservations.airtrail.imported': '{count} flight(s) imported', + 'reservations.airtrail.skippedDuplicate': '{count} already in this trip, skipped', + 'reservations.airtrail.nothingImported': 'Nothing to import.', + 'reservations.airtrail.importError': 'Import failed. Please try again.', + 'reservations.airtrail.undo': 'Import from AirTrail', + 'reservations.airtrail.alreadyImported': 'Imported', + 'reservations.airtrail.duringTrip': 'During this trip', + 'reservations.airtrail.otherFlights': 'Other flights', + 'reservations.airtrail.empty': 'No flights found in your AirTrail account.', + 'reservations.airtrail.importCta': 'Import {count}', }; export default reservations; diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index 2cfb0189..fc3380d4 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -318,6 +318,21 @@ const settings: TranslationStrings = { 'settings.passkey.deviceBound': 'This device', 'settings.passkey.lastUsed': 'Last used', 'settings.passkey.neverUsed': 'Never used', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Connect your self-hosted AirTrail to import and sync flights. Create an API key in AirTrail under Settings → Security.', + 'settings.airtrail.url': 'Instance URL', + 'settings.airtrail.apiKey': 'API key', + 'settings.airtrail.apiKeyPlaceholder': 'Bearer API key', + 'settings.airtrail.apiKeyHint': 'Generated in AirTrail under Settings → Security. Stored encrypted.', + 'settings.airtrail.allowInsecureTls': 'Allow self-signed certificates', + 'settings.airtrail.allowInsecureTlsHint': 'Enable only for a trusted instance on your own network.', + 'settings.airtrail.connected': 'Connected', + 'settings.airtrail.notConnected': 'Not connected', + 'settings.airtrail.toast.saved': 'AirTrail connection saved', + 'settings.airtrail.toast.saveError': 'Could not save the connection', + 'settings.airtrail.test.button': 'Test connection', + 'settings.airtrail.test.success': 'Connected — {count} flight(s) found', + 'settings.airtrail.test.failed': 'Connection failed', }; export default settings; diff --git a/shared/src/i18n/es/reservations.ts b/shared/src/i18n/es/reservations.ts index 26723284..b0222160 100644 --- a/shared/src/i18n/es/reservations.ts +++ b/shared/src/i18n/es/reservations.ts @@ -141,5 +141,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'La importación de reservas no está disponible en este servidor.', 'reservations.import.unsupportedFormat': 'Formato de archivo no compatible. Usa EML, PDF, PKPass, HTML o TXT.', 'reservations.import.fileTooLarge': 'El archivo «{name}» supera el límite de 10 MB.', + 'reservations.airtrail.title': 'Importar desde AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Sincronizado desde AirTrail: las ediciones se mantienen sincronizadas en ambos sentidos.', + 'reservations.airtrail.notSynced': 'No sincronizado', + 'reservations.airtrail.notSyncedHint': 'Este vuelo se eliminó en AirTrail y ya no se sincroniza.', + 'reservations.airtrail.loadError': 'No se pudieron cargar tus vuelos de AirTrail.', + 'reservations.airtrail.imported': '{count} vuelo(s) importado(s)', + 'reservations.airtrail.skippedDuplicate': '{count} ya en este viaje, omitido(s)', + 'reservations.airtrail.nothingImported': 'No hay nada que importar.', + 'reservations.airtrail.importError': 'Error al importar. Inténtalo de nuevo.', + 'reservations.airtrail.undo': 'Importar desde AirTrail', + 'reservations.airtrail.alreadyImported': 'Importado', + 'reservations.airtrail.duringTrip': 'Durante este viaje', + 'reservations.airtrail.otherFlights': 'Otros vuelos', + 'reservations.airtrail.empty': 'No se encontraron vuelos en tu cuenta de AirTrail.', + 'reservations.airtrail.importCta': 'Importar {count}', }; export default reservations; diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index adce60a1..0e804dad 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -326,6 +326,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Nunca usada', 'settings.mapPoiPill': 'Explorar lugares en el mapa', 'settings.mapPoiPillHint': 'Muestra una píldora de categorías en el mapa del viaje para encontrar restaurantes, alojamientos y más cerca, desde OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Conecta tu AirTrail autoalojado para importar y sincronizar vuelos. Crea una clave de API en AirTrail en Ajustes → Seguridad.', + 'settings.airtrail.url': 'URL de la instancia', + 'settings.airtrail.apiKey': 'Clave de API', + 'settings.airtrail.apiKeyPlaceholder': 'Clave de API Bearer', + 'settings.airtrail.apiKeyHint': 'Generada en AirTrail en Ajustes → Seguridad. Se almacena cifrada.', + 'settings.airtrail.allowInsecureTls': 'Permitir certificados autofirmados', + 'settings.airtrail.allowInsecureTlsHint': 'Actívalo solo para una instancia de confianza en tu propia red.', + 'settings.airtrail.connected': 'Conectado', + 'settings.airtrail.notConnected': 'No conectado', + 'settings.airtrail.toast.saved': 'Conexión con AirTrail guardada', + 'settings.airtrail.toast.saveError': 'No se pudo guardar la conexión', + 'settings.airtrail.test.button': 'Probar conexión', + 'settings.airtrail.test.success': 'Conectado: {count} vuelo(s) encontrado(s)', + 'settings.airtrail.test.failed': 'Error de conexión', }; export default settings; diff --git a/shared/src/i18n/fr/reservations.ts b/shared/src/i18n/fr/reservations.ts index a76892a9..e69a09fc 100644 --- a/shared/src/i18n/fr/reservations.ts +++ b/shared/src/i18n/fr/reservations.ts @@ -142,5 +142,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': "L'import de réservations n'est pas disponible sur ce serveur.", 'reservations.import.unsupportedFormat': 'Format de fichier non pris en charge. Utilisez EML, PDF, PKPass, HTML ou TXT.', 'reservations.import.fileTooLarge': 'Le fichier « {name} » dépasse la limite de 10 Mo.', + 'reservations.airtrail.title': 'Importer depuis AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Synchronisé depuis AirTrail — les modifications restent synchronisées dans les deux sens.', + 'reservations.airtrail.notSynced': 'Non synchronisé', + 'reservations.airtrail.notSyncedHint': "Ce vol a été supprimé dans AirTrail et n'est plus synchronisé.", + 'reservations.airtrail.loadError': 'Impossible de charger vos vols AirTrail.', + 'reservations.airtrail.imported': '{count} vol(s) importé(s)', + 'reservations.airtrail.skippedDuplicate': '{count} déjà dans ce voyage, ignoré(s)', + 'reservations.airtrail.nothingImported': 'Rien à importer.', + 'reservations.airtrail.importError': "Échec de l'importation. Veuillez réessayer.", + 'reservations.airtrail.undo': 'Importer depuis AirTrail', + 'reservations.airtrail.alreadyImported': 'Importé', + 'reservations.airtrail.duringTrip': 'Pendant ce voyage', + 'reservations.airtrail.otherFlights': 'Autres vols', + 'reservations.airtrail.empty': 'Aucun vol trouvé dans votre compte AirTrail.', + 'reservations.airtrail.importCta': 'Importer {count}', }; export default reservations; diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 9b874b77..d196f56c 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -331,6 +331,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Jamais utilisée', 'settings.mapPoiPill': 'Explorer les lieux sur la carte', 'settings.mapPoiPillHint': 'Afficher une pastille de catégorie sur la carte du voyage pour trouver à proximité des restaurants, hébergements et plus encore depuis OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Connectez votre instance AirTrail auto-hébergée pour importer et synchroniser vos vols. Créez une clé API dans AirTrail sous Paramètres → Sécurité.', + 'settings.airtrail.url': "URL de l'instance", + 'settings.airtrail.apiKey': 'Clé API', + 'settings.airtrail.apiKeyPlaceholder': 'Clé API Bearer', + 'settings.airtrail.apiKeyHint': 'Générée dans AirTrail sous Paramètres → Sécurité. Stockée chiffrée.', + 'settings.airtrail.allowInsecureTls': 'Autoriser les certificats auto-signés', + 'settings.airtrail.allowInsecureTlsHint': 'À activer uniquement pour une instance de confiance sur votre propre réseau.', + 'settings.airtrail.connected': 'Connecté', + 'settings.airtrail.notConnected': 'Non connecté', + 'settings.airtrail.toast.saved': 'Connexion AirTrail enregistrée', + 'settings.airtrail.toast.saveError': "Impossible d'enregistrer la connexion", + 'settings.airtrail.test.button': 'Tester la connexion', + 'settings.airtrail.test.success': 'Connecté — {count} vol(s) trouvé(s)', + 'settings.airtrail.test.failed': 'Échec de la connexion', }; export default settings; diff --git a/shared/src/i18n/gr/reservations.ts b/shared/src/i18n/gr/reservations.ts index 1a4ce8ed..2f56a769 100644 --- a/shared/src/i18n/gr/reservations.ts +++ b/shared/src/i18n/gr/reservations.ts @@ -143,5 +143,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'Η εισαγωγή κρατήσεων δεν είναι διαθέσιμη σε αυτόν τον διακομιστή.', 'reservations.import.unsupportedFormat': 'Μη υποστηριζόμενη μορφή αρχείου. Χρησιμοποιήστε EML, PDF, PKPass, HTML ή TXT.', 'reservations.import.fileTooLarge': 'Το αρχείο «{name}» υπερβαίνει το όριο των 10 MB.', + 'reservations.airtrail.title': 'Εισαγωγή από το AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Συγχρονισμένο από το AirTrail — οι αλλαγές συγχρονίζονται και προς τις δύο κατευθύνσεις.', + 'reservations.airtrail.notSynced': 'Μη συγχρονισμένο', + 'reservations.airtrail.notSyncedHint': 'Αυτή η πτήση αφαιρέθηκε στο AirTrail και δεν συγχρονίζεται πλέον.', + 'reservations.airtrail.loadError': 'Δεν ήταν δυνατή η φόρτωση των πτήσεών σας από το AirTrail.', + 'reservations.airtrail.imported': '{count} πτήση/πτήσεις εισήχθησαν', + 'reservations.airtrail.skippedDuplicate': '{count} υπάρχουν ήδη σε αυτό το ταξίδι, παραλείφθηκαν', + 'reservations.airtrail.nothingImported': 'Δεν υπάρχει τίποτα για εισαγωγή.', + 'reservations.airtrail.importError': 'Η εισαγωγή απέτυχε. Δοκιμάστε ξανά.', + 'reservations.airtrail.undo': 'Εισαγωγή από το AirTrail', + 'reservations.airtrail.alreadyImported': 'Εισήχθη', + 'reservations.airtrail.duringTrip': 'Κατά τη διάρκεια αυτού του ταξιδιού', + 'reservations.airtrail.otherFlights': 'Άλλες πτήσεις', + 'reservations.airtrail.empty': 'Δεν βρέθηκαν πτήσεις στον λογαριασμό σας στο AirTrail.', + 'reservations.airtrail.importCta': 'Εισαγωγή {count}', }; export default reservations; diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index ff5a4483..e8674ee2 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -332,6 +332,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Δεν χρησιμοποιήθηκε ποτέ', 'settings.mapPoiPill': 'Εξερεύνηση μερών στον χάρτη', 'settings.mapPoiPillHint': 'Εμφάνιση ετικέτας κατηγορίας στον χάρτη του ταξιδιού για εύρεση κοντινών εστιατορίων, ξενοδοχείων και άλλων από το OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Συνδέστε το αυτο-φιλοξενούμενο AirTrail σας για εισαγωγή και συγχρονισμό πτήσεων. Δημιουργήστε ένα κλειδί API στο AirTrail από Ρυθμίσεις → Ασφάλεια.', + 'settings.airtrail.url': 'URL της εγκατάστασης', + 'settings.airtrail.apiKey': 'Κλειδί API', + 'settings.airtrail.apiKeyPlaceholder': 'Κλειδί API τύπου Bearer', + 'settings.airtrail.apiKeyHint': 'Δημιουργείται στο AirTrail από Ρυθμίσεις → Ασφάλεια. Αποθηκεύεται κρυπτογραφημένο.', + 'settings.airtrail.allowInsecureTls': 'Να επιτρέπονται αυτο-υπογεγραμμένα πιστοποιητικά', + 'settings.airtrail.allowInsecureTlsHint': 'Ενεργοποιήστε το μόνο για μια αξιόπιστη εγκατάσταση στο δικό σας δίκτυο.', + 'settings.airtrail.connected': 'Συνδέθηκε', + 'settings.airtrail.notConnected': 'Δεν συνδέθηκε', + 'settings.airtrail.toast.saved': 'Η σύνδεση με το AirTrail αποθηκεύτηκε', + 'settings.airtrail.toast.saveError': 'Δεν ήταν δυνατή η αποθήκευση της σύνδεσης', + 'settings.airtrail.test.button': 'Δοκιμή σύνδεσης', + 'settings.airtrail.test.success': 'Συνδέθηκε — βρέθηκαν {count} πτήση/πτήσεις', + 'settings.airtrail.test.failed': 'Η σύνδεση απέτυχε', }; export default settings; diff --git a/shared/src/i18n/hu/reservations.ts b/shared/src/i18n/hu/reservations.ts index 5d5f83ba..a60eb475 100644 --- a/shared/src/i18n/hu/reservations.ts +++ b/shared/src/i18n/hu/reservations.ts @@ -142,5 +142,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'A foglalásimportálás nem érhető el ezen a kiszolgálón.', 'reservations.import.unsupportedFormat': 'Nem támogatott fájlformátum. Használjon EML, PDF, PKPass, HTML vagy TXT formátumot.', 'reservations.import.fileTooLarge': 'A(z) „{name}" fájl meghaladja a 10 MB-os korlátot.', + 'reservations.airtrail.title': 'Importálás az AirTrailből', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Az AirTrailből szinkronizálva — a módosítások mindkét irányban szinkronban maradnak.', + 'reservations.airtrail.notSynced': 'Nincs szinkronizálva', + 'reservations.airtrail.notSyncedHint': 'Ezt a járatot eltávolították az AirTrailből, és többé nem szinkronizálódik.', + 'reservations.airtrail.loadError': 'Nem sikerült betölteni az AirTrail-járataidat.', + 'reservations.airtrail.imported': '{count} járat importálva', + 'reservations.airtrail.skippedDuplicate': '{count} már szerepel ebben az utazásban, kihagyva', + 'reservations.airtrail.nothingImported': 'Nincs mit importálni.', + 'reservations.airtrail.importError': 'Az importálás sikertelen. Kérjük, próbáld újra.', + 'reservations.airtrail.undo': 'Importálás az AirTrailből', + 'reservations.airtrail.alreadyImported': 'Importálva', + 'reservations.airtrail.duringTrip': 'Az utazás ideje alatt', + 'reservations.airtrail.otherFlights': 'Egyéb járatok', + 'reservations.airtrail.empty': 'Nem található járat az AirTrail-fiókodban.', + 'reservations.airtrail.importCta': '{count} importálása', }; export default reservations; diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 1aad1699..2ea67e6a 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -328,6 +328,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Még nem használt', 'settings.mapPoiPill': 'Helyek felfedezése a térképen', 'settings.mapPoiPillHint': 'Megjelenít egy kategóriasávot az utazási térképen, hogy az OpenStreetMap segítségével közeli éttermeket, szállásokat és továbbiakat találj.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Csatlakoztasd a saját üzemeltetésű AirTrail-példányodat járatok importálásához és szinkronizálásához. Hozz létre egy API-kulcsot az AirTrailben a Beállítások → Biztonság menüpontban.', + 'settings.airtrail.url': 'Példány URL-címe', + 'settings.airtrail.apiKey': 'API-kulcs', + 'settings.airtrail.apiKeyPlaceholder': 'Bearer API-kulcs', + 'settings.airtrail.apiKeyHint': 'Az AirTrailben a Beállítások → Biztonság menüpontban generálva. Titkosítva tárolva.', + 'settings.airtrail.allowInsecureTls': 'Önaláírt tanúsítványok engedélyezése', + 'settings.airtrail.allowInsecureTlsHint': 'Csak megbízható, saját hálózaton futó példány esetén engedélyezd.', + 'settings.airtrail.connected': 'Csatlakoztatva', + 'settings.airtrail.notConnected': 'Nincs csatlakoztatva', + 'settings.airtrail.toast.saved': 'AirTrail-kapcsolat mentve', + 'settings.airtrail.toast.saveError': 'Nem sikerült menteni a kapcsolatot', + 'settings.airtrail.test.button': 'Kapcsolat tesztelése', + 'settings.airtrail.test.success': 'Csatlakoztatva — {count} járat található', + 'settings.airtrail.test.failed': 'A kapcsolat sikertelen', }; export default settings; diff --git a/shared/src/i18n/id/reservations.ts b/shared/src/i18n/id/reservations.ts index d8ac5be1..5a3ca2fd 100644 --- a/shared/src/i18n/id/reservations.ts +++ b/shared/src/i18n/id/reservations.ts @@ -141,5 +141,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'Impor pemesanan tidak tersedia di server ini.', 'reservations.import.unsupportedFormat': 'Format file tidak didukung. Gunakan EML, PDF, PKPass, HTML, atau TXT.', 'reservations.import.fileTooLarge': 'File "{name}" melebihi batas 10 MB.', + 'reservations.airtrail.title': 'Impor dari AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Tersinkron dari AirTrail — perubahan tetap sinkron di kedua arah.', + 'reservations.airtrail.notSynced': 'Tidak tersinkron', + 'reservations.airtrail.notSyncedHint': 'Penerbangan ini telah dihapus di AirTrail dan tidak lagi tersinkron.', + 'reservations.airtrail.loadError': 'Tidak dapat memuat penerbangan AirTrail-mu.', + 'reservations.airtrail.imported': '{count} penerbangan diimpor', + 'reservations.airtrail.skippedDuplicate': '{count} sudah ada di perjalanan ini, dilewati', + 'reservations.airtrail.nothingImported': 'Tidak ada yang dapat diimpor.', + 'reservations.airtrail.importError': 'Impor gagal. Silakan coba lagi.', + 'reservations.airtrail.undo': 'Impor dari AirTrail', + 'reservations.airtrail.alreadyImported': 'Diimpor', + 'reservations.airtrail.duringTrip': 'Selama perjalanan ini', + 'reservations.airtrail.otherFlights': 'Penerbangan lain', + 'reservations.airtrail.empty': 'Tidak ada penerbangan ditemukan di akun AirTrail-mu.', + 'reservations.airtrail.importCta': 'Impor {count}', }; export default reservations; diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 19281bc8..dac5c269 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -326,6 +326,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Belum pernah digunakan', 'settings.mapPoiPill': 'Jelajahi tempat di peta', 'settings.mapPoiPillHint': 'Tampilkan pil kategori di peta perjalanan untuk menemukan restoran, hotel, dan lainnya di sekitar dari OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Hubungkan AirTrail yang kamu host sendiri untuk mengimpor dan menyinkronkan penerbangan. Buat kunci API di AirTrail pada Pengaturan → Keamanan.', + 'settings.airtrail.url': 'URL Instans', + 'settings.airtrail.apiKey': 'Kunci API', + 'settings.airtrail.apiKeyPlaceholder': 'Kunci API Bearer', + 'settings.airtrail.apiKeyHint': 'Dibuat di AirTrail pada Pengaturan → Keamanan. Disimpan terenkripsi.', + 'settings.airtrail.allowInsecureTls': 'Izinkan sertifikat yang ditandatangani sendiri', + 'settings.airtrail.allowInsecureTlsHint': 'Aktifkan hanya untuk instans tepercaya di jaringanmu sendiri.', + 'settings.airtrail.connected': 'Terhubung', + 'settings.airtrail.notConnected': 'Tidak terhubung', + 'settings.airtrail.toast.saved': 'Koneksi AirTrail disimpan', + 'settings.airtrail.toast.saveError': 'Tidak dapat menyimpan koneksi', + 'settings.airtrail.test.button': 'Uji koneksi', + 'settings.airtrail.test.success': 'Terhubung — {count} penerbangan ditemukan', + 'settings.airtrail.test.failed': 'Koneksi gagal', }; export default settings; diff --git a/shared/src/i18n/it/reservations.ts b/shared/src/i18n/it/reservations.ts index 374880fd..b38efd37 100644 --- a/shared/src/i18n/it/reservations.ts +++ b/shared/src/i18n/it/reservations.ts @@ -143,5 +143,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': "L'importazione di prenotazioni non è disponibile su questo server.", 'reservations.import.unsupportedFormat': 'Formato file non supportato. Usa EML, PDF, PKPass, HTML o TXT.', 'reservations.import.fileTooLarge': 'Il file "{name}" supera il limite di 10 MB.', + 'reservations.airtrail.title': 'Importa da AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Sincronizzato da AirTrail — le modifiche restano sincronizzate in entrambe le direzioni.', + 'reservations.airtrail.notSynced': 'Non sincronizzato', + 'reservations.airtrail.notSyncedHint': 'Questo volo è stato rimosso in AirTrail e non si sincronizza più.', + 'reservations.airtrail.loadError': 'Impossibile caricare i tuoi voli AirTrail.', + 'reservations.airtrail.imported': '{count} volo/i importato/i', + 'reservations.airtrail.skippedDuplicate': '{count} già presente/i in questo viaggio, ignorato/i', + 'reservations.airtrail.nothingImported': 'Niente da importare.', + 'reservations.airtrail.importError': 'Importazione fallita. Riprova.', + 'reservations.airtrail.undo': 'Importa da AirTrail', + 'reservations.airtrail.alreadyImported': 'Importato', + 'reservations.airtrail.duringTrip': 'Durante questo viaggio', + 'reservations.airtrail.otherFlights': 'Altri voli', + 'reservations.airtrail.empty': 'Nessun volo trovato nel tuo account AirTrail.', + 'reservations.airtrail.importCta': 'Importa {count}', }; export default reservations; diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 44510383..acc504b8 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -325,6 +325,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Mai usata', 'settings.mapPoiPill': 'Esplora luoghi sulla mappa', 'settings.mapPoiPillHint': 'Mostra un selettore di categorie sulla mappa del viaggio per trovare ristoranti, hotel e altro nelle vicinanze da OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Collega il tuo AirTrail self-hosted per importare e sincronizzare i voli. Crea una chiave API in AirTrail in Impostazioni → Sicurezza.', + 'settings.airtrail.url': 'URL istanza', + 'settings.airtrail.apiKey': 'Chiave API', + 'settings.airtrail.apiKeyPlaceholder': 'Chiave API Bearer', + 'settings.airtrail.apiKeyHint': 'Generata in AirTrail in Impostazioni → Sicurezza. Memorizzata crittografata.', + 'settings.airtrail.allowInsecureTls': 'Consenti certificati autofirmati', + 'settings.airtrail.allowInsecureTlsHint': 'Abilita solo per un\'istanza attendibile sulla tua rete.', + 'settings.airtrail.connected': 'Connesso', + 'settings.airtrail.notConnected': 'Non connesso', + 'settings.airtrail.toast.saved': 'Connessione AirTrail salvata', + 'settings.airtrail.toast.saveError': 'Impossibile salvare la connessione', + 'settings.airtrail.test.button': 'Prova connessione', + 'settings.airtrail.test.success': 'Connesso — {count} volo/i trovato/i', + 'settings.airtrail.test.failed': 'Connessione fallita', }; export default settings; diff --git a/shared/src/i18n/ja/reservations.ts b/shared/src/i18n/ja/reservations.ts index 4acd2180..73e630f0 100644 --- a/shared/src/i18n/ja/reservations.ts +++ b/shared/src/i18n/ja/reservations.ts @@ -139,5 +139,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'このサーバーでは予約インポート機能が利用できません。', 'reservations.import.unsupportedFormat': '対応していないファイル形式です。EML、PDF、PKPass、HTML、または TXT を使用してください。', 'reservations.import.fileTooLarge': 'ファイル「{name}」は 10 MB の制限を超えています。', + 'reservations.airtrail.title': 'AirTrail からインポート', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'AirTrail と同期済み — 編集は双方向で同期されます。', + 'reservations.airtrail.notSynced': '未同期', + 'reservations.airtrail.notSyncedHint': 'このフライトは AirTrail で削除されたため、同期されなくなりました。', + 'reservations.airtrail.loadError': 'AirTrail のフライトを読み込めませんでした。', + 'reservations.airtrail.imported': '{count} 件のフライトをインポートしました', + 'reservations.airtrail.skippedDuplicate': '{count} 件はこの旅行に既に存在するためスキップしました', + 'reservations.airtrail.nothingImported': 'インポートする項目がありません。', + 'reservations.airtrail.importError': 'インポートに失敗しました。もう一度お試しください。', + 'reservations.airtrail.undo': 'AirTrail からインポート', + 'reservations.airtrail.alreadyImported': 'インポート済み', + 'reservations.airtrail.duringTrip': 'この旅行の期間中', + 'reservations.airtrail.otherFlights': 'その他のフライト', + 'reservations.airtrail.empty': 'AirTrail アカウントにフライトが見つかりませんでした。', + 'reservations.airtrail.importCta': '{count} 件をインポート', }; export default reservations; diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index b687f24a..6646077d 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -305,6 +305,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': '未使用', 'settings.mapPoiPill': '地図でスポットを探す', 'settings.mapPoiPillHint': '旅行の地図にカテゴリピルを表示して、OpenStreetMapから近くのレストランや宿泊施設などを見つけられます。', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'セルフホストの AirTrail を接続して、フライトをインポート・同期します。AirTrail の「設定 → セキュリティ」で API キーを作成してください。', + 'settings.airtrail.url': 'インスタンス URL', + 'settings.airtrail.apiKey': 'API キー', + 'settings.airtrail.apiKeyPlaceholder': 'Bearer API キー', + 'settings.airtrail.apiKeyHint': 'AirTrail の「設定 → セキュリティ」で生成します。暗号化して保存されます。', + 'settings.airtrail.allowInsecureTls': '自己署名証明書を許可する', + 'settings.airtrail.allowInsecureTlsHint': '自分のネットワーク内の信頼できるインスタンスの場合にのみ有効にしてください。', + 'settings.airtrail.connected': '接続済み', + 'settings.airtrail.notConnected': '未接続', + 'settings.airtrail.toast.saved': 'AirTrail の接続を保存しました', + 'settings.airtrail.toast.saveError': '接続を保存できませんでした', + 'settings.airtrail.test.button': '接続をテスト', + 'settings.airtrail.test.success': '接続成功 — {count} 件のフライトが見つかりました', + 'settings.airtrail.test.failed': '接続に失敗しました', }; export default settings; diff --git a/shared/src/i18n/ko/reservations.ts b/shared/src/i18n/ko/reservations.ts index 5072f916..544c8a8d 100644 --- a/shared/src/i18n/ko/reservations.ts +++ b/shared/src/i18n/ko/reservations.ts @@ -139,5 +139,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': '이 서버에서는 예약 가져오기를 사용할 수 없습니다.', 'reservations.import.unsupportedFormat': '지원하지 않는 파일 형식입니다. EML, PDF, PKPass, HTML 또는 TXT를 사용하세요.', 'reservations.import.fileTooLarge': '파일 "{name}"이(가) 10 MB 제한을 초과합니다.', + 'reservations.airtrail.title': 'AirTrail에서 가져오기', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'AirTrail에서 동기화됨 — 수정 사항이 양방향으로 동기화됩니다.', + 'reservations.airtrail.notSynced': '동기화되지 않음', + 'reservations.airtrail.notSyncedHint': '이 항공편은 AirTrail에서 삭제되어 더 이상 동기화되지 않습니다.', + 'reservations.airtrail.loadError': 'AirTrail 항공편을 불러올 수 없습니다.', + 'reservations.airtrail.imported': '{count}개 항공편을 가져왔습니다', + 'reservations.airtrail.skippedDuplicate': '{count}개는 이미 이 여행에 있어 건너뛰었습니다', + 'reservations.airtrail.nothingImported': '가져올 항목이 없습니다.', + 'reservations.airtrail.importError': '가져오기에 실패했습니다. 다시 시도하세요.', + 'reservations.airtrail.undo': 'AirTrail에서 가져오기', + 'reservations.airtrail.alreadyImported': '가져옴', + 'reservations.airtrail.duringTrip': '이 여행 기간', + 'reservations.airtrail.otherFlights': '기타 항공편', + 'reservations.airtrail.empty': 'AirTrail 계정에서 항공편을 찾을 수 없습니다.', + 'reservations.airtrail.importCta': '{count}개 가져오기', }; export default reservations; diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 1ee22766..d34b03a1 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -322,6 +322,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': '사용한 적 없음', 'settings.mapPoiPill': '지도에서 장소 탐색', 'settings.mapPoiPillHint': '여행 지도에 카테고리 칩을 표시하여 OpenStreetMap에서 주변 음식점, 숙소 등을 찾아보세요.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': '자체 호스팅한 AirTrail을 연결하여 항공편을 가져오고 동기화하세요. AirTrail의 설정 → 보안에서 API 키를 생성하세요.', + 'settings.airtrail.url': '인스턴스 URL', + 'settings.airtrail.apiKey': 'API 키', + 'settings.airtrail.apiKeyPlaceholder': 'Bearer API 키', + 'settings.airtrail.apiKeyHint': 'AirTrail의 설정 → 보안에서 생성됩니다. 암호화하여 저장됩니다.', + 'settings.airtrail.allowInsecureTls': '자체 서명 인증서 허용', + 'settings.airtrail.allowInsecureTlsHint': '자체 네트워크의 신뢰할 수 있는 인스턴스에서만 활성화하세요.', + 'settings.airtrail.connected': '연결됨', + 'settings.airtrail.notConnected': '연결되지 않음', + 'settings.airtrail.toast.saved': 'AirTrail 연결이 저장되었습니다', + 'settings.airtrail.toast.saveError': '연결을 저장할 수 없습니다', + 'settings.airtrail.test.button': '연결 테스트', + 'settings.airtrail.test.success': '연결됨 — {count}개 항공편을 찾았습니다', + 'settings.airtrail.test.failed': '연결에 실패했습니다', }; export default settings; diff --git a/shared/src/i18n/nl/reservations.ts b/shared/src/i18n/nl/reservations.ts index 06b6fdbb..40615f14 100644 --- a/shared/src/i18n/nl/reservations.ts +++ b/shared/src/i18n/nl/reservations.ts @@ -142,5 +142,24 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'Boeking importeren is niet beschikbaar op deze server.', 'reservations.import.unsupportedFormat': 'Niet-ondersteund bestandsformaat. Gebruik EML, PDF, PKPass, HTML of TXT.', 'reservations.import.fileTooLarge': 'Bestand "{name}" overschrijdt de limiet van 10 MB.', + 'reservations.airtrail.title': 'Importeren uit AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': + 'Gesynchroniseerd vanuit AirTrail — wijzigingen blijven beide kanten op gesynchroniseerd.', + 'reservations.airtrail.notSynced': 'Niet gesynchroniseerd', + 'reservations.airtrail.notSyncedHint': + 'Deze vlucht is in AirTrail verwijderd en wordt niet meer gesynchroniseerd.', + 'reservations.airtrail.loadError': 'Je AirTrail-vluchten konden niet worden geladen.', + 'reservations.airtrail.imported': '{count} vlucht(en) geïmporteerd', + 'reservations.airtrail.skippedDuplicate': '{count} al in deze reis, overgeslagen', + 'reservations.airtrail.nothingImported': 'Niets om te importeren.', + 'reservations.airtrail.importError': 'Importeren mislukt. Probeer het opnieuw.', + 'reservations.airtrail.undo': 'Importeren uit AirTrail', + 'reservations.airtrail.alreadyImported': 'Geïmporteerd', + 'reservations.airtrail.duringTrip': 'Tijdens deze reis', + 'reservations.airtrail.otherFlights': 'Andere vluchten', + 'reservations.airtrail.empty': 'Geen vluchten gevonden in je AirTrail-account.', + 'reservations.airtrail.importCta': '{count} importeren', }; export default reservations; diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index c63ca62d..6639f4c1 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -325,6 +325,24 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Nooit gebruikt', 'settings.mapPoiPill': 'Plaatsen op de kaart ontdekken', 'settings.mapPoiPillHint': 'Toon een categorielabel op de reiskaart om restaurants, hotels en meer in de buurt te vinden via OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': + 'Verbind je zelf-gehoste AirTrail om vluchten te importeren en te synchroniseren. Maak een API-sleutel aan in AirTrail onder Instellingen → Beveiliging.', + 'settings.airtrail.url': 'Instantie-URL', + 'settings.airtrail.apiKey': 'API-sleutel', + 'settings.airtrail.apiKeyPlaceholder': 'Bearer API-sleutel', + 'settings.airtrail.apiKeyHint': + 'Aangemaakt in AirTrail onder Instellingen → Beveiliging. Versleuteld opgeslagen.', + 'settings.airtrail.allowInsecureTls': 'Zelfondertekende certificaten toestaan', + 'settings.airtrail.allowInsecureTlsHint': + 'Schakel dit alleen in voor een vertrouwde instantie op je eigen netwerk.', + 'settings.airtrail.connected': 'Verbonden', + 'settings.airtrail.notConnected': 'Niet verbonden', + 'settings.airtrail.toast.saved': 'AirTrail-verbinding opgeslagen', + 'settings.airtrail.toast.saveError': 'De verbinding kon niet worden opgeslagen', + 'settings.airtrail.test.button': 'Verbinding testen', + 'settings.airtrail.test.success': 'Verbonden — {count} vlucht(en) gevonden', + 'settings.airtrail.test.failed': 'Verbinding mislukt', }; export default settings; diff --git a/shared/src/i18n/pl/reservations.ts b/shared/src/i18n/pl/reservations.ts index 01444301..3a20a7a3 100644 --- a/shared/src/i18n/pl/reservations.ts +++ b/shared/src/i18n/pl/reservations.ts @@ -142,5 +142,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'Import rezerwacji nie jest dostępny na tym serwerze.', 'reservations.import.unsupportedFormat': 'Nieobsługiwany format pliku. Użyj EML, PDF, PKPass, HTML lub TXT.', 'reservations.import.fileTooLarge': 'Plik „{name}" przekracza limit 10 MB.', + 'reservations.airtrail.title': 'Importuj z AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Zsynchronizowano z AirTrail — zmiany są synchronizowane w obie strony.', + 'reservations.airtrail.notSynced': 'Niezsynchronizowane', + 'reservations.airtrail.notSyncedHint': 'Ten lot został usunięty w AirTrail i nie jest już synchronizowany.', + 'reservations.airtrail.loadError': 'Nie udało się wczytać Twoich lotów z AirTrail.', + 'reservations.airtrail.imported': 'Zaimportowano {count} lot(y/ów)', + 'reservations.airtrail.skippedDuplicate': '{count} już w tej wyprawie, pominięto', + 'reservations.airtrail.nothingImported': 'Nic do zaimportowania.', + 'reservations.airtrail.importError': 'Import nieudany. Spróbuj ponownie.', + 'reservations.airtrail.undo': 'Importuj z AirTrail', + 'reservations.airtrail.alreadyImported': 'Zaimportowano', + 'reservations.airtrail.duringTrip': 'Podczas tej wyprawy', + 'reservations.airtrail.otherFlights': 'Inne loty', + 'reservations.airtrail.empty': 'Nie znaleziono lotów na Twoim koncie AirTrail.', + 'reservations.airtrail.importCta': 'Importuj {count}', }; export default reservations; diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index fc3199e0..43db4f7b 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -327,6 +327,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Nigdy nieużywany', 'settings.mapPoiPill': 'Odkrywaj miejsca na mapie', 'settings.mapPoiPillHint': 'Pokaż na mapie wyprawy pasek z kategoriami, aby znaleźć pobliskie restauracje, hotele i więcej z OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Połącz swój własny AirTrail, aby importować i synchronizować loty. Utwórz klucz API w AirTrail w sekcji Ustawienia → Bezpieczeństwo.', + 'settings.airtrail.url': 'Adres URL instancji', + 'settings.airtrail.apiKey': 'Klucz API', + 'settings.airtrail.apiKeyPlaceholder': 'Klucz API typu Bearer', + 'settings.airtrail.apiKeyHint': 'Wygenerowany w AirTrail w sekcji Ustawienia → Bezpieczeństwo. Przechowywany w postaci zaszyfrowanej.', + 'settings.airtrail.allowInsecureTls': 'Zezwalaj na certyfikaty samopodpisane', + 'settings.airtrail.allowInsecureTlsHint': 'Włącz tylko dla zaufanej instancji we własnej sieci.', + 'settings.airtrail.connected': 'Połączono', + 'settings.airtrail.notConnected': 'Nie połączono', + 'settings.airtrail.toast.saved': 'Zapisano połączenie z AirTrail', + 'settings.airtrail.toast.saveError': 'Nie udało się zapisać połączenia', + 'settings.airtrail.test.button': 'Testuj połączenie', + 'settings.airtrail.test.success': 'Połączono — znaleziono {count} lot(y/ów)', + 'settings.airtrail.test.failed': 'Połączenie nieudane', }; export default settings; diff --git a/shared/src/i18n/ru/reservations.ts b/shared/src/i18n/ru/reservations.ts index d4ae5d08..dc0aceda 100644 --- a/shared/src/i18n/ru/reservations.ts +++ b/shared/src/i18n/ru/reservations.ts @@ -142,5 +142,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'Импорт бронирований недоступен на этом сервере.', 'reservations.import.unsupportedFormat': 'Неподдерживаемый формат файла. Используйте EML, PDF, PKPass, HTML или TXT.', 'reservations.import.fileTooLarge': 'Файл «{name}» превышает ограничение в 10 МБ.', + 'reservations.airtrail.title': 'Импорт из AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Синхронизировано с AirTrail — изменения синхронизируются в обе стороны.', + 'reservations.airtrail.notSynced': 'Не синхронизировано', + 'reservations.airtrail.notSyncedHint': 'Этот рейс был удалён в AirTrail и больше не синхронизируется.', + 'reservations.airtrail.loadError': 'Не удалось загрузить ваши рейсы из AirTrail.', + 'reservations.airtrail.imported': 'Импортировано рейсов: {count}', + 'reservations.airtrail.skippedDuplicate': '{count} уже в этой поездке, пропущено', + 'reservations.airtrail.nothingImported': 'Нечего импортировать.', + 'reservations.airtrail.importError': 'Импорт не удался. Повторите попытку.', + 'reservations.airtrail.undo': 'Импорт из AirTrail', + 'reservations.airtrail.alreadyImported': 'Импортировано', + 'reservations.airtrail.duringTrip': 'Во время этой поездки', + 'reservations.airtrail.otherFlights': 'Другие рейсы', + 'reservations.airtrail.empty': 'В вашей учётной записи AirTrail не найдено рейсов.', + 'reservations.airtrail.importCta': 'Импортировать {count}', }; export default reservations; diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 5cf67ac4..ac02578f 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -325,6 +325,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Не использовался', 'settings.mapPoiPill': 'Поиск мест на карте', 'settings.mapPoiPillHint': 'Показывать на карте поездки кнопку категорий, чтобы находить рядом рестораны, отели и другие места из OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Подключите свой self-hosted AirTrail для импорта и синхронизации рейсов. Создайте ключ API в AirTrail в разделе «Настройки → Безопасность».', + 'settings.airtrail.url': 'URL экземпляра', + 'settings.airtrail.apiKey': 'Ключ API', + 'settings.airtrail.apiKeyPlaceholder': 'Bearer-ключ API', + 'settings.airtrail.apiKeyHint': 'Создаётся в AirTrail в разделе «Настройки → Безопасность». Хранится в зашифрованном виде.', + 'settings.airtrail.allowInsecureTls': 'Разрешить самоподписанные сертификаты', + 'settings.airtrail.allowInsecureTlsHint': 'Включайте только для доверенного экземпляра в вашей собственной сети.', + 'settings.airtrail.connected': 'Подключено', + 'settings.airtrail.notConnected': 'Не подключено', + 'settings.airtrail.toast.saved': 'Подключение к AirTrail сохранено', + 'settings.airtrail.toast.saveError': 'Не удалось сохранить подключение', + 'settings.airtrail.test.button': 'Проверить подключение', + 'settings.airtrail.test.success': 'Подключено — найдено рейсов: {count}', + 'settings.airtrail.test.failed': 'Не удалось подключиться', }; export default settings; diff --git a/shared/src/i18n/tr/reservations.ts b/shared/src/i18n/tr/reservations.ts index d4dd9165..ba661801 100644 --- a/shared/src/i18n/tr/reservations.ts +++ b/shared/src/i18n/tr/reservations.ts @@ -142,5 +142,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'Rezervasyon içe aktarma bu sunucuda mevcut değil.', 'reservations.import.unsupportedFormat': 'Desteklenmeyen dosya biçimi. EML, PDF, PKPass, HTML veya TXT kullanın.', 'reservations.import.fileTooLarge': '"{name}" dosyası 10 MB sınırını aşıyor.', + 'reservations.airtrail.title': 'AirTrail\'den içe aktar', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'AirTrail ile senkronize edildi — düzenlemeler iki yönlü olarak senkronize kalır.', + 'reservations.airtrail.notSynced': 'Senkronize değil', + 'reservations.airtrail.notSyncedHint': 'Bu uçuş AirTrail\'de kaldırıldı ve artık senkronize edilmiyor.', + 'reservations.airtrail.loadError': 'AirTrail uçuşlarınız yüklenemedi.', + 'reservations.airtrail.imported': '{count} uçuş içe aktarıldı', + 'reservations.airtrail.skippedDuplicate': '{count} zaten bu gezide, atlandı', + 'reservations.airtrail.nothingImported': 'İçe aktarılacak bir şey yok.', + 'reservations.airtrail.importError': 'İçe aktarma başarısız. Lütfen tekrar deneyin.', + 'reservations.airtrail.undo': 'AirTrail\'den içe aktar', + 'reservations.airtrail.alreadyImported': 'İçe aktarıldı', + 'reservations.airtrail.duringTrip': 'Bu gezi sırasında', + 'reservations.airtrail.otherFlights': 'Diğer uçuşlar', + 'reservations.airtrail.empty': 'AirTrail hesabınızda uçuş bulunamadı.', + 'reservations.airtrail.importCta': '{count} içe aktar', }; export default reservations; diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 17e5c9e8..99c61264 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -326,6 +326,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Hiç kullanılmadı', 'settings.mapPoiPill': 'Haritada yerleri keşfet', 'settings.mapPoiPillHint': 'Yakındaki restoranları, otelleri ve daha fazlasını OpenStreetMap\'ten bulmak için gezi haritasında bir kategori etiketi göster.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Uçuşları içe aktarmak ve senkronize etmek için kendi barındırdığınız AirTrail\'i bağlayın. AirTrail\'de Ayarlar → Güvenlik altından bir API anahtarı oluşturun.', + 'settings.airtrail.url': 'Örnek URL\'si', + 'settings.airtrail.apiKey': 'API anahtarı', + 'settings.airtrail.apiKeyPlaceholder': 'Bearer API anahtarı', + 'settings.airtrail.apiKeyHint': 'AirTrail\'de Ayarlar → Güvenlik altında oluşturulur. Şifreli olarak saklanır.', + 'settings.airtrail.allowInsecureTls': 'Kendinden imzalı sertifikalara izin ver', + 'settings.airtrail.allowInsecureTlsHint': 'Yalnızca kendi ağınızdaki güvenilir bir örnek için etkinleştirin.', + 'settings.airtrail.connected': 'Bağlandı', + 'settings.airtrail.notConnected': 'Bağlı değil', + 'settings.airtrail.toast.saved': 'AirTrail bağlantısı kaydedildi', + 'settings.airtrail.toast.saveError': 'Bağlantı kaydedilemedi', + 'settings.airtrail.test.button': 'Bağlantıyı test et', + 'settings.airtrail.test.success': 'Bağlandı — {count} uçuş bulundu', + 'settings.airtrail.test.failed': 'Bağlantı başarısız', }; export default settings; diff --git a/shared/src/i18n/uk/reservations.ts b/shared/src/i18n/uk/reservations.ts index 454ca7b8..9cc10173 100644 --- a/shared/src/i18n/uk/reservations.ts +++ b/shared/src/i18n/uk/reservations.ts @@ -142,5 +142,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': 'Імпорт бронювань недоступний на цьому сервері.', 'reservations.import.unsupportedFormat': 'Непідтримуваний формат файлу. Використовуйте EML, PDF, PKPass, HTML або TXT.', 'reservations.import.fileTooLarge': 'Файл «{name}» перевищує обмеження в 10 МБ.', + 'reservations.airtrail.title': 'Імпорт з AirTrail', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': 'Синхронізовано з AirTrail — зміни синхронізуються в обидва боки.', + 'reservations.airtrail.notSynced': 'Не синхронізовано', + 'reservations.airtrail.notSyncedHint': 'Цей рейс було видалено в AirTrail і він більше не синхронізується.', + 'reservations.airtrail.loadError': 'Не вдалося завантажити ваші рейси з AirTrail.', + 'reservations.airtrail.imported': '{count} рейс(ів) імпортовано', + 'reservations.airtrail.skippedDuplicate': '{count} вже в цій подорожі, пропущено', + 'reservations.airtrail.nothingImported': 'Немає чого імпортувати.', + 'reservations.airtrail.importError': 'Імпорт не вдався. Спробуйте ще раз.', + 'reservations.airtrail.undo': 'Імпорт з AirTrail', + 'reservations.airtrail.alreadyImported': 'Імпортовано', + 'reservations.airtrail.duringTrip': 'Під час цієї подорожі', + 'reservations.airtrail.otherFlights': 'Інші рейси', + 'reservations.airtrail.empty': 'У вашому акаунті AirTrail не знайдено рейсів.', + 'reservations.airtrail.importCta': 'Імпортувати {count}', }; export default reservations; diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index d86899ca..dd54c33d 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -324,6 +324,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Не використовувався', 'settings.mapPoiPill': 'Досліджуйте місця на карті', 'settings.mapPoiPillHint': 'Показувати на карті подорожі плашку категорій, щоб знаходити поблизу ресторани, готелі та інше з OpenStreetMap.', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': 'Підключіть свій самостійно розміщений AirTrail, щоб імпортувати та синхронізувати рейси. Створіть API-ключ в AirTrail у розділі Налаштування → Безпека.', + 'settings.airtrail.url': 'URL екземпляра', + 'settings.airtrail.apiKey': 'API-ключ', + 'settings.airtrail.apiKeyPlaceholder': 'Bearer API-ключ', + 'settings.airtrail.apiKeyHint': 'Згенеровано в AirTrail у розділі Налаштування → Безпека. Зберігається в зашифрованому вигляді.', + 'settings.airtrail.allowInsecureTls': 'Дозволити самопідписані сертифікати', + 'settings.airtrail.allowInsecureTlsHint': 'Вмикайте лише для довіреного екземпляра у вашій власній мережі.', + 'settings.airtrail.connected': 'Підключено', + 'settings.airtrail.notConnected': 'Не підключено', + 'settings.airtrail.toast.saved': 'Підключення AirTrail збережено', + 'settings.airtrail.toast.saveError': 'Не вдалося зберегти підключення', + 'settings.airtrail.test.button': 'Перевірити підключення', + 'settings.airtrail.test.success': 'Підключено — знайдено {count} рейс(ів)', + 'settings.airtrail.test.failed': 'Не вдалося підключитися', }; export default settings; diff --git a/shared/src/i18n/zh-TW/reservations.ts b/shared/src/i18n/zh-TW/reservations.ts index 50482ba1..638524a8 100644 --- a/shared/src/i18n/zh-TW/reservations.ts +++ b/shared/src/i18n/zh-TW/reservations.ts @@ -138,5 +138,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': '此伺服器上的預訂匯入功能不可用。', 'reservations.import.unsupportedFormat': '不支援的檔案格式。請使用 EML、PDF、PKPass、HTML 或 TXT。', 'reservations.import.fileTooLarge': '檔案「{name}」超過 10 MB 限制。', + 'reservations.airtrail.title': '從 AirTrail 匯入', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': '已從 AirTrail 同步——編輯會雙向保持同步。', + 'reservations.airtrail.notSynced': '未同步', + 'reservations.airtrail.notSyncedHint': '此航班已在 AirTrail 中移除,不再同步。', + 'reservations.airtrail.loadError': '無法載入你的 AirTrail 航班。', + 'reservations.airtrail.imported': '已匯入 {count} 筆航班', + 'reservations.airtrail.skippedDuplicate': '{count} 筆已在此行程中,已略過', + 'reservations.airtrail.nothingImported': '沒有可匯入的項目。', + 'reservations.airtrail.importError': '匯入失敗。請再試一次。', + 'reservations.airtrail.undo': '從 AirTrail 匯入', + 'reservations.airtrail.alreadyImported': '已匯入', + 'reservations.airtrail.duringTrip': '行程期間', + 'reservations.airtrail.otherFlights': '其他航班', + 'reservations.airtrail.empty': '在你的 AirTrail 帳戶中找不到任何航班。', + 'reservations.airtrail.importCta': '匯入 {count}', }; export default reservations; diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 6b9ce24f..aaf8cbc0 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -311,6 +311,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': '從未使用', 'settings.mapPoiPill': '在地圖上探索地點', 'settings.mapPoiPillHint': '在行程地圖上顯示分類標籤,透過 OpenStreetMap 尋找附近的餐廳、住宿等地點。', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': '連接你自架的 AirTrail 以匯入及同步航班。在 AirTrail 的「設定 → 安全性」中建立 API 金鑰。', + 'settings.airtrail.url': '執行個體網址', + 'settings.airtrail.apiKey': 'API 金鑰', + 'settings.airtrail.apiKeyPlaceholder': 'Bearer API 金鑰', + 'settings.airtrail.apiKeyHint': '在 AirTrail 的「設定 → 安全性」中產生。以加密方式儲存。', + 'settings.airtrail.allowInsecureTls': '允許自簽憑證', + 'settings.airtrail.allowInsecureTlsHint': '僅在你自己網路上受信任的執行個體啟用。', + 'settings.airtrail.connected': '已連接', + 'settings.airtrail.notConnected': '未連接', + 'settings.airtrail.toast.saved': '已儲存 AirTrail 連接', + 'settings.airtrail.toast.saveError': '無法儲存連接', + 'settings.airtrail.test.button': '測試連接', + 'settings.airtrail.test.success': '已連接——找到 {count} 筆航班', + 'settings.airtrail.test.failed': '連接失敗', }; export default settings; diff --git a/shared/src/i18n/zh/reservations.ts b/shared/src/i18n/zh/reservations.ts index 298bafbb..85b91a64 100644 --- a/shared/src/i18n/zh/reservations.ts +++ b/shared/src/i18n/zh/reservations.ts @@ -138,5 +138,22 @@ const reservations: TranslationStrings = { 'reservations.import.unavailable': '此服务器上的预订导入功能不可用。', 'reservations.import.unsupportedFormat': '不支持的文件格式。请使用 EML、PDF、PKPass、HTML 或 TXT。', 'reservations.import.fileTooLarge': '文件"{name}"超过 10 MB 限制。', + 'reservations.airtrail.title': '从 AirTrail 导入', + 'reservations.airtrail.cta': 'AirTrail', + 'reservations.airtrail.synced': 'AirTrail', + 'reservations.airtrail.syncedHint': '已从 AirTrail 同步——编辑会双向保持同步。', + 'reservations.airtrail.notSynced': '未同步', + 'reservations.airtrail.notSyncedHint': '此航班已在 AirTrail 中删除,不再同步。', + 'reservations.airtrail.loadError': '无法加载您的 AirTrail 航班。', + 'reservations.airtrail.imported': '已导入 {count} 个航班', + 'reservations.airtrail.skippedDuplicate': '{count} 个已在此行程中,已跳过', + 'reservations.airtrail.nothingImported': '没有可导入的内容。', + 'reservations.airtrail.importError': '导入失败。请重试。', + 'reservations.airtrail.undo': '从 AirTrail 导入', + 'reservations.airtrail.alreadyImported': '已导入', + 'reservations.airtrail.duringTrip': '行程期间', + 'reservations.airtrail.otherFlights': '其他航班', + 'reservations.airtrail.empty': '您的 AirTrail 账户中未找到航班。', + 'reservations.airtrail.importCta': '导入 {count}', }; export default reservations; diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index 88be4f5f..0729c916 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -310,6 +310,21 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': '从未使用', 'settings.mapPoiPill': '在地图上探索地点', 'settings.mapPoiPillHint': '在行程地图上显示分类标签,从 OpenStreetMap 查找附近的餐厅、酒店等。', + 'settings.airtrail.title': 'AirTrail', + 'settings.airtrail.hint': '连接您的自托管 AirTrail 以导入和同步航班。在 AirTrail 的“设置 → 安全”中创建 API 密钥。', + 'settings.airtrail.url': '实例 URL', + 'settings.airtrail.apiKey': 'API 密钥', + 'settings.airtrail.apiKeyPlaceholder': 'Bearer API 密钥', + 'settings.airtrail.apiKeyHint': '在 AirTrail 的“设置 → 安全”中生成。加密存储。', + 'settings.airtrail.allowInsecureTls': '允许自签名证书', + 'settings.airtrail.allowInsecureTlsHint': '仅对您自己网络中受信任的实例启用。', + 'settings.airtrail.connected': '已连接', + 'settings.airtrail.notConnected': '未连接', + 'settings.airtrail.toast.saved': 'AirTrail 连接已保存', + 'settings.airtrail.toast.saveError': '无法保存连接', + 'settings.airtrail.test.button': '测试连接', + 'settings.airtrail.test.success': '已连接——找到 {count} 个航班', + 'settings.airtrail.test.failed': '连接失败', }; export default settings; diff --git a/shared/src/index.ts b/shared/src/index.ts index 43b48604..881ce3a6 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -26,6 +26,7 @@ export * from './packing/packing.schema'; export * from './todo/todo.schema'; export * from './budget/budget.schema'; export * from './reservation/reservation.schema'; +export * from './airtrail/airtrail.schema'; export * from './day/day.schema'; export * from './assignment/assignment.schema'; export * from './place/place.schema'; diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts index ee5ab5c1..5a6d090d 100644 --- a/shared/src/reservation/reservation.schema.ts +++ b/shared/src/reservation/reservation.schema.ts @@ -61,6 +61,12 @@ export const reservationSchema = z.object({ needs_review: z.number().optional(), day_plan_position: z.number().nullable().optional(), created_at: z.string().optional(), + // AirTrail (or future provider) linkage — drives the "synced" badge (#214). + external_source: z.string().nullable().optional(), + external_id: z.string().nullable().optional(), + external_owner_user_id: z.number().nullable().optional(), + external_synced_at: z.string().nullable().optional(), + sync_enabled: z.number().nullable().optional(), // joined / computed in listReservations day_number: z.number().nullable().optional(), place_name: z.string().nullable().optional(),