AirTrail integration: import flights & two-way sync (#214) (#1158)

* feat(admin): register AirTrail as an integration addon

Off by default; toggle lives in Admin -> Addons with a Plane icon. The
per-user connection (URL + API key) follows in integration settings.

* feat(integrations): add per-user AirTrail connection

Settings -> Integrations gains an AirTrail section: instance URL + Bearer
API key (encrypted at rest via apiKeyCrypto), a self-signed-TLS opt-in and
a test-connection check. Served by a small Nest controller under
/api/integrations/airtrail, gated on the airtrail addon and SSRF-guarded.
The key is per-user, so it only ever returns that user's own flights.

* feat(transport): import flights from AirTrail

Adds an AirTrail Import button next to Manual Transport that lists the
user's AirTrail flights and highlights the ones inside the trip dates.
Selected flights become reservations linked to their AirTrail origin
(external_* columns), deduped against flights already in the trip, then
broadcast to every member. The mapping resolves airports, airport-local
times and flight metadata; the linkage is what the two-way sync rides on.

* feat(transport): badge AirTrail-linked flights as synced

Linked reservations show an 'AirTrail synced' badge, or 'no longer
synced' once the flight is gone from AirTrail.

* feat(transport): keep TREK and AirTrail flights in sync both ways

A scheduled poll reconciles each connected owner's flights: field edits
(detected by snapshot hash, since AirTrail has no updated_at) flow into
the linked reservation and broadcast live; a flight deleted in AirTrail
keeps the TREK row but stops syncing. Editing a linked flight in TREK
pushes back to AirTrail under the importer's credentials, preserving the
existing seat manifest; if the owner disconnected the link detaches so the
poll can't revert the local edit. Deleting in TREK never touches AirTrail.

* i18n(airtrail): add AirTrail strings across all locales

* test(airtrail): cover flight mapping, timezones and snapshot hashing

* fix(airtrail): reduce airline/aircraft objects to codes

The flight list/get response returns airline and aircraft as joined
objects ({icao, iata, name, ...}), not bare codes. Mapping them straight
through produced '[object Object]' titles and stored objects in metadata,
which crashed reservation rendering. Extract the ICAO/IATA code instead,
and title flights by their flight number.

* fix(airtrail): clear error on non-JSON responses, tolerate /api in URL

A misconfigured instance URL made AirTrail serve its SPA/login HTML, and
the raw JSON.parse failure surfaced as 'Unexpected token <'. Surface an
actionable message instead, and strip a pasted trailing /api so the base
URL still resolves.

* feat(transport): sync AirTrail edits on trip open, not just on the poll

Add a per-user on-demand sync (POST /integrations/airtrail/sync) triggered
when a connected user opens a trip, so AirTrail-side edits appear right away
instead of waiting up to a full poll cycle. Lower the background poll from 15
to 5 minutes as a safety net.

* fix(transport): refresh imported AirTrail flights without a reload

loadTrip doesn't fetch reservations, so a freshly imported flight only
appeared after a full page reload — use loadReservations instead. Also show
flight dates in the user's locale format (e.g. 13.06.2026) rather than the
raw ISO string.

* style(settings): align AirTrail connection with the photo-provider layout

Match the Immich section: stacked URL/key fields, a ToggleSwitch for
self-signed TLS, and a Save / Test-connection row with a status badge.

* feat(transport): add a seat field when editing flights

The transport editor only offered a seat field for trains; flights had
none even though imports store metadata.seat. Show and persist a seat for
flights too.

* style(transport): match the AirTrail button height to Manual Transport

* feat(transport): put the flight seat next to flight number and sync it to AirTrail

Move the seat from a standalone row to the per-leg flight details (beside
the flight number), stored per leg in metadata.legs[].seat with the first
leg mirrored to metadata.seat. On push, set the seat number on the user's
own AirTrail seat (the one with a userId), leaving co-passengers untouched;
import/poll read that same seat back.

* refactor(planner): move the AirTrail trip-open sync into useTripPlanner

Page containers must not own state/effects (lint:pages). Same logic,
relocated from the page into its data hook.

* test(db): pin the region-reconciliation test to its schema version

The test re-ran 'the last migration' assuming the reconciliation is last;
it no longer is once later migrations are appended. Pin to version 135 and
re-run from there (the appended migrations are idempotent).
This commit is contained in:
Maurice
2026-06-13 13:11:35 +02:00
committed by GitHub
parent f91721c73e
commit 56655d53b4
72 changed files with 2565 additions and 14 deletions
+14
View File
@@ -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),
+2 -2
View File
@@ -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 }) {
@@ -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) => 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<EventTarget | null>(null)
const [loading, setLoading] = useState(false)
const [importing, setImporting] = useState(false)
const [error, setError] = useState('')
const [flights, setFlights] = useState<AirtrailFlight[]>([])
const [selected, setSelected] = useState<Set<string>>(() => new Set())
// AirTrail flight ids already linked to a reservation in this trip.
const importedIds = useMemo(() => {
const set = new Set<string>()
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<string>()
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 (
<button
key={f.id}
onClick={() => !already && toggle(f.id)}
disabled={already}
className={already ? 'bg-surface-tertiary' : isSelected ? 'bg-surface-secondary' : 'bg-transparent'}
style={{
width: '100%', textAlign: 'left', borderRadius: 10, padding: '10px 12px', marginBottom: 8,
border: `1px solid ${isSelected && !already ? 'var(--accent)' : 'var(--border-primary)'}`,
opacity: already ? 0.55 : 1, cursor: already ? 'default' : 'pointer',
display: 'flex', gap: 10, alignItems: 'center', fontFamily: 'inherit',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<span style={{
flexShrink: 0, width: 18, height: 18, borderRadius: 5,
border: `1.5px solid ${isSelected || already ? 'var(--accent)' : 'var(--border-primary)'}`,
background: isSelected || already ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{(isSelected || already) && <Check size={12} color="var(--accent-text)" strokeWidth={3} />}
</span>
<Plane size={15} color="#3b82f6" style={{ flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<span style={{ display: 'block', fontSize: 11, color: 'var(--text-muted)' }}>
{f.fromCode ?? f.fromName ?? '?'} {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
</span>
</span>
{already && (
<span style={{ flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>
{t('reservations.airtrail.alreadyImported')}
</span>
)}
</button>
)
}
return ReactDOM.createPortal(
<div
className="bg-[rgba(0,0,0,0.4)]"
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
mouseDownTarget.current = null
}}
>
<div
onClick={e => 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' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<Plane size={16} color="#3b82f6" />
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('reservations.airtrail.title')}
</div>
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
<X size={16} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{loading && (
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
{t('common.loading')}
</div>
)}
{!loading && flights.length === 0 && !error && (
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
{t('reservations.airtrail.empty')}
</div>
)}
{!loading && during.length > 0 && (
<>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', margin: '2px 0 8px' }}>
{t('reservations.airtrail.duringTrip')}
</div>
{during.map(renderFlight)}
</>
)}
{!loading && others.length > 0 && (
<>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-faint)', margin: `${during.length > 0 ? 14 : 2}px 0 8px` }}>
{t('reservations.airtrail.otherFlights')}
</div>
{others.map(renderFlight)}
</>
)}
{error && (
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
{error}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
<button
onClick={handleClose}
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
onClick={handleImport}
disabled={selectableCount === 0 || importing}
className={selectableCount > 0 && !importing ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: selectableCount > 0 && !importing ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{importing ? t('common.loading') : t('reservations.airtrail.importCta', { count: selectableCount })}
</button>
</div>
</div>
</div>,
document.body,
)
}
@@ -179,6 +179,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('reservations.needsReview')}
</span>
) : null}
{r.external_source === 'airtrail' ? (
<span
className={r.sync_enabled ? 'text-[#2563eb] bg-[rgba(59,130,246,0.12)]' : 'text-content-faint bg-surface-tertiary'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, padding: '3px 8px', borderRadius: 6 }}
title={r.sync_enabled ? t('reservations.airtrail.syncedHint') : t('reservations.airtrail.notSyncedHint')}
>
<Plane size={11} />
{r.sync_enabled ? t('reservations.airtrail.synced') : t('reservations.airtrail.notSynced')}
</span>
) : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="text-content" style={{
@@ -472,6 +482,8 @@ interface ReservationsPanelProps {
onAdd: () => 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
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
</button>
)}
{onAirTrailImport && airTrailAvailable && (
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{
appearance: 'none', border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500, boxSizing: 'border-box',
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.75'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
title={t('reservations.airtrail.title')}
>
<Plane size={14} strokeWidth={2} />
<span className="hidden sm:inline">{t('reservations.airtrail.cta')}</span>
</button>
)}
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
@@ -77,9 +77,10 @@ interface WaypointForm {
depTime: string
airline: string
flight_number: string
seat: string
}
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '' }
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
}
const TYPE_OPTIONS = [
@@ -197,6 +198,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
}
})
} else {
@@ -206,6 +208,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
dep.airline = meta.airline ?? ''
dep.flight_number = meta.flight_number ?? ''
dep.seat = meta.seat ?? ''
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
arr.airport = airportFromEndpoint(to)
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
@@ -271,6 +274,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
to: next.airport!.iata,
...(w.airline ? { airline: w.airline } : {}),
...(w.flight_number ? { flight_number: w.flight_number } : {}),
...(w.seat ? { seat: w.seat } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -279,6 +283,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
})
}
if (firstWp?.seat) metadata.seat = firstWp.seat
} else if (form.type === 'train') {
if (form.meta_train_number) metadata.train_number = form.meta_train_number
if (form.meta_platform) metadata.platform = form.meta_platform
@@ -501,7 +506,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.airline')}</label>
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
@@ -510,6 +515,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
</div>
</div>
</>
)}
@@ -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 (
<Section title={t('settings.airtrail.title')} icon={Plane}>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
<input
type="url"
value={url}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.apiKey')}</label>
<input
type="password"
value={apiKey}
onChange={e => 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"
/>
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.apiKeyHint')}</p>
</div>
<div className="flex items-center gap-3">
<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} />
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={handleSave}
disabled={saving || loading || !canSave}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
>
<Save className="w-4 h-4" /> {t('common.save')}
</button>
<button
onClick={handleTest}
disabled={testing || loading || !url.trim()}
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
>
{testing ? (
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
) : (
<Plane className="w-4 h-4" />
)}
{t('settings.airtrail.test.button')}
</button>
{connected ? (
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" />
{t('settings.airtrail.connected')}
</span>
) : (
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('settings.airtrail.notConnected')}
</span>
)}
</div>
<p className="text-xs text-slate-500">{t('settings.airtrail.hint')}</p>
</div>
</Section>
)
}
@@ -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 (
<>
<PhotoProvidersSection />
{S.airtrailEnabled && <AirTrailConnectionSection />}
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
<McpTokenModals {...S} />
<OAuthClientModals {...S} />
@@ -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,
}
}
+31
View File
@@ -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 }
}
+5
View File
@@ -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 {
<ReservationModal isOpen={showReservationModal} onClose={() => { 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 && <TransportModal isOpen={showTransportModal} onClose={() => { 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)} />}
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
<ConfirmDialog
isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)}
+2 -1
View File
@@ -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<string | null>(null)
const [activeTab, setActiveTab] = useState('display')
+15 -1
View File
@@ -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<Reservation | null>(null)
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
const { available: airTrailAvailable } = useAirtrailConnection()
const [showAirTrailImport, setShowAirTrailImport] = useState<boolean>(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<number | null>(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<number | null>(null)
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(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,
+1
View File
@@ -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];
+29
View File
@@ -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) {
+1
View File
@@ -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);
+1
View File
@@ -79,6 +79,7 @@ const onListen = () => {
scheduler.startDemoReset();
scheduler.startIdempotencyCleanup();
scheduler.startTrekPhotoCacheCleanup();
scheduler.startAirTrailSync();
const { startTokenCleanup } = require('./services/ephemeralTokens');
startTokenCleanup();
import('./websocket').then(({ setupWebSocket }) => {
+4 -2
View File
@@ -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,
@@ -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;
}
}
@@ -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<AirtrailImportResult> {
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);
}
}
}
@@ -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);
}
}
@@ -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 {}
@@ -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<string, unknown> & {
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 };
}
+27 -1
View File
@@ -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 };
@@ -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 <key>`; 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 '<USER_ID>' 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<T>(resp: Response): Promise<T> {
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<Response> {
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<AirtrailFlightRaw[]> {
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<AirtrailFlightRaw | null> {
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 };
}
@@ -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<AirtrailImportResult> {
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<string>();
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;
}
@@ -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<string, unknown>;
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<string, unknown> = {};
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');
}
@@ -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<AirtrailFlight[]> {
const creds = getAirtrailCredentials(userId);
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
const raw = await listFlights(creds);
return raw.map(normalizeFlight);
}
@@ -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<number> {
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<void> {
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<string, any> = {};
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: '<USER_ID>', 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<void> {
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}`);
}
}
@@ -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);
}
@@ -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<AirtrailFlightRaw['from']> = {}): NonNullable<AirtrailFlightRaw['from']> {
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> = {}): 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));
});
});
+79
View File
@@ -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<typeof airtrailSettingsSchema>;
export const airtrailConnectionSchema = z.object({
url: z.string(),
apiKeyMasked: z.string(),
allowInsecureTls: z.boolean(),
connected: z.boolean(),
});
export type AirtrailConnection = z.infer<typeof airtrailConnectionSchema>;
export const airtrailStatusSchema = z.object({
connected: z.boolean(),
flightCount: z.number().optional(),
error: z.string().optional(),
});
export type AirtrailStatus = z.infer<typeof airtrailStatusSchema>;
// ── 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<typeof airtrailFlightSchema>;
// ── Import ───────────────────────────────────────────────────────────────────
export const airtrailImportSchema = z.object({
flightIds: z.array(z.string()).min(1, 'Select at least one flight'),
});
export type AirtrailImport = z.infer<typeof airtrailImportSchema>;
/** 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<typeof airtrailImportResultSchema>;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+19
View File
@@ -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;
+18
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+17
View File
@@ -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;
+15
View File
@@ -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;
+1
View File
@@ -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';
@@ -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(),