mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
3 Commits
f91721c73e
...
3398da633b
| Author | SHA1 | Date | |
|---|---|---|---|
| 3398da633b | |||
| 31f99f0e4e | |||
| 56655d53b4 |
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -1708,4 +1708,49 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onEditTransport).toHaveBeenCalledWith(res)
|
||||
expect(onEditReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── showRouteToolsWhenExpanded (mobile route tools) ───────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-099: showRouteToolsWhenExpanded shows route tools on expanded day without selection', () => {
|
||||
const places = [
|
||||
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
|
||||
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
|
||||
]
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assigns = {
|
||||
'10': [
|
||||
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
|
||||
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places, assignments: assigns, selectedDayId: null, showRouteToolsWhenExpanded: true,
|
||||
})} />)
|
||||
// Days are expanded by default, so route tools must be visible even with no selected day
|
||||
expect(screen.getByRole('button', { name: /optimize/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-100: optimize via showRouteToolsWhenExpanded reorders the expanded day', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onReorder = vi.fn().mockResolvedValue(undefined)
|
||||
const places = [
|
||||
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
|
||||
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
|
||||
buildPlace({ id: 3, name: 'C', lat: 48.87, lng: 2.37 }),
|
||||
]
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assigns = {
|
||||
'10': [
|
||||
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
|
||||
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
|
||||
buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }),
|
||||
],
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places, assignments: assigns, selectedDayId: null, onReorder, showRouteToolsWhenExpanded: true,
|
||||
})} />)
|
||||
const optimizeBtn = screen.getByRole('button', { name: /optimize/i })
|
||||
await user.click(optimizeBtn)
|
||||
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,6 +84,8 @@ interface DayPlanSidebarProps {
|
||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||
initialScrollTop?: number
|
||||
onScrollTopChange?: (top: number) => void
|
||||
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
|
||||
showRouteToolsWhenExpanded?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +127,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded = false,
|
||||
} = props
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -742,9 +745,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
||||
}
|
||||
|
||||
const handleOptimize = async () => {
|
||||
if (!selectedDayId) return
|
||||
const da = getDayAssignments(selectedDayId)
|
||||
const handleOptimize = async (dayId: number | null = selectedDayId) => {
|
||||
if (!dayId) return
|
||||
const da = getDayAssignments(dayId)
|
||||
if (da.length < 3) return
|
||||
|
||||
const prevIds = da.map(a => a.id)
|
||||
@@ -764,7 +767,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
||||
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
|
||||
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
|
||||
const day = days.find(d => d.id === selectedDayId)
|
||||
const day = days.find(d => d.id === dayId)
|
||||
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
|
||||
? getAccommodationAnchors(day, days, accommodations)
|
||||
: {}
|
||||
@@ -781,10 +784,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (!result[i]) result[i] = optimizedQueue[qi++]
|
||||
}
|
||||
|
||||
await onReorder(selectedDayId, result.map(a => a.id))
|
||||
await onReorder(dayId, result.map(a => a.id))
|
||||
const usedHotel = !!(anchors.start || anchors.end)
|
||||
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
|
||||
const capturedDayId = selectedDayId
|
||||
const capturedDayId = dayId
|
||||
pushUndo?.(t('undo.optimize'), async () => {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
||||
})
|
||||
@@ -901,6 +904,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -1047,6 +1051,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
showRouteToolsWhenExpanded,
|
||||
toast,
|
||||
t,
|
||||
language,
|
||||
@@ -2096,7 +2101,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
|
||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && (
|
||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<button
|
||||
@@ -2112,7 +2117,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<RouteIcon size={12} strokeWidth={2} />
|
||||
{t('dayplan.route')}
|
||||
</button>
|
||||
<button onClick={handleOptimize} className="bg-surface-hover text-content-secondary" style={{
|
||||
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
@@ -2141,7 +2146,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{routeInfo && (
|
||||
{isSelected && routeInfo && (
|
||||
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}>
|
||||
<span>{routeInfo.distance}</span>
|
||||
<span className="text-content-faint">·</span>
|
||||
|
||||
@@ -39,6 +39,31 @@ interface PlaceFormModalProps {
|
||||
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
||||
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
||||
* render over the form fields. */
|
||||
|
||||
// #1152: a manually-added place is treated as a likely duplicate of an existing
|
||||
// trip place if it shares the Google Place ID, the (case-insensitive) name, or
|
||||
// near-identical coordinates (~11 m). Mirrors the server-side import dedup.
|
||||
const DUP_COORD_TOLERANCE = 0.0001
|
||||
function findDuplicatePlace(
|
||||
form: PlaceFormData,
|
||||
places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[],
|
||||
): { name?: string | null } | null {
|
||||
const name = (form.name || '').trim().toLowerCase()
|
||||
const gid = (form.google_place_id || '').trim()
|
||||
const lat = form.lat ? parseFloat(form.lat) : null
|
||||
const lng = form.lng ? parseFloat(form.lng) : null
|
||||
for (const p of places || []) {
|
||||
if (gid && p.google_place_id && p.google_place_id === gid) return p
|
||||
if (name && p.name && p.name.trim().toLowerCase() === name) return p
|
||||
if (
|
||||
lat != null && lng != null && p.lat != null && p.lng != null &&
|
||||
Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE &&
|
||||
Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE
|
||||
) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const {
|
||||
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
||||
@@ -51,6 +76,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const fileRef = useRef(null)
|
||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||
@@ -94,6 +120,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setForm(DEFAULT_FORM)
|
||||
}
|
||||
setPendingFiles([])
|
||||
setDuplicateWarning(null)
|
||||
}, [place, prefillCoords, isOpen])
|
||||
|
||||
// Derive location bias bounding box from the trip's existing places
|
||||
@@ -309,6 +336,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
toast.error(t('places.nameRequired'))
|
||||
return
|
||||
}
|
||||
// #1152: only for new places, and only on the first attempt — a second click
|
||||
// (with the warning already showing) is the explicit "add anyway" confirmation.
|
||||
if (!place && !duplicateWarning) {
|
||||
const dup = findDuplicatePlace(form, places)
|
||||
if (dup) {
|
||||
const dupName = dup.name || form.name
|
||||
setDuplicateWarning(dupName)
|
||||
toast.warning(t('places.duplicateExists', { name: dupName }))
|
||||
return
|
||||
}
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave({
|
||||
@@ -381,6 +419,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
handlePaste,
|
||||
hasTimeError,
|
||||
handleSubmit,
|
||||
duplicateWarning,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +480,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
handlePaste,
|
||||
hasTimeError,
|
||||
handleSubmit,
|
||||
duplicateWarning,
|
||||
} = S
|
||||
return (
|
||||
<Modal
|
||||
@@ -463,7 +503,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
disabled={isSaving || hasTimeError}
|
||||
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
||||
>
|
||||
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
|
||||
{isSaving ? t('common.saving') : place ? t('common.update') : duplicateWarning ? t('places.addAnyway') : t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -491,6 +491,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
||||
placeholder="000000 or XXXX-XXXX"
|
||||
required
|
||||
autoFocus
|
||||
style={inputBase}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||
|
||||
@@ -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,
|
||||
@@ -614,7 +616,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
@@ -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)}
|
||||
|
||||
@@ -15,7 +15,7 @@ interface AdminUsersTabProps {
|
||||
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
|
||||
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
|
||||
const {
|
||||
serverTimezone, hour12, currentUser,
|
||||
hour12, currentUser,
|
||||
users, isLoading,
|
||||
setShowCreateUser,
|
||||
invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
||||
@@ -92,10 +92,10 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
||||
{new Date(u.created_at).toLocaleDateString(locale)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
|
||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
@@ -162,7 +162,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">
|
||||
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
|
||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
|
||||
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -79,6 +79,7 @@ const onListen = () => {
|
||||
scheduler.startDemoReset();
|
||||
scheduler.startIdempotencyCleanup();
|
||||
scheduler.startTrekPhotoCacheCleanup();
|
||||
scheduler.startAirTrailSync();
|
||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||
startTokenCleanup();
|
||||
import('./websocket').then(({ setupWebSocket }) => {
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import { broadcastToUser } from '../websocket';
|
||||
import { getAction } from './inAppNotificationActions';
|
||||
import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService';
|
||||
|
||||
// SQLite's CURRENT_TIMESTAMP is UTC but the string ('YYYY-MM-DD HH:MM:SS') has
|
||||
// no 'T'/'Z', so `new Date(...)` parses it as LOCAL time. Normalize to ISO-UTC
|
||||
// so the client renders notification times in the viewer's own timezone (#1149).
|
||||
function toUtcIso(ts: string): string {
|
||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
|
||||
type NotificationType = 'simple' | 'boolean' | 'navigate';
|
||||
type NotificationScope = 'trip' | 'user' | 'admin';
|
||||
type NotificationResponse = 'positive' | 'negative';
|
||||
@@ -218,6 +225,7 @@ export function createNotificationForRecipient(
|
||||
type: 'notification:new',
|
||||
notification: {
|
||||
...row,
|
||||
created_at: toUtcIso(row.created_at),
|
||||
sender_username: sender?.username ?? null,
|
||||
sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null,
|
||||
},
|
||||
@@ -251,6 +259,7 @@ function getNotifications(
|
||||
|
||||
const mapped = rows.map(r => ({
|
||||
...r,
|
||||
created_at: toUtcIso(r.created_at),
|
||||
sender_avatar: r.sender_avatar ? `/uploads/avatars/${r.sender_avatar}` : null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -86,5 +86,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'فشل إنشاء الفئة',
|
||||
'places.nameRequired': 'يرجى إدخال اسم',
|
||||
'places.saveError': 'فشل الحفظ',
|
||||
'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.",
|
||||
'places.addAnyway': 'الإضافة على أي حال',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Falha ao criar categoria',
|
||||
'places.nameRequired': 'Digite um nome',
|
||||
'places.saveError': 'Falha ao salvar',
|
||||
'places.duplicateExists': "'{name}' já está nesta viagem.",
|
||||
'places.addAnyway': 'Adicionar mesmo assim',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -87,5 +87,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii',
|
||||
'places.nameRequired': 'Prosím zadejte název',
|
||||
'places.saveError': 'Uložení se nezdařilo',
|
||||
'places.duplicateExists': "'{name}' už v tomto výletu existuje.",
|
||||
'places.addAnyway': 'Přesto přidat',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
|
||||
'places.nameRequired': 'Bitte einen Namen eingeben',
|
||||
'places.saveError': 'Fehler beim Speichern',
|
||||
'places.duplicateExists': "'{name}' ist bereits in dieser Reise.",
|
||||
'places.addAnyway': 'Trotzdem hinzufügen',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -87,5 +87,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Failed to create category',
|
||||
'places.nameRequired': 'Please enter a name',
|
||||
'places.saveError': 'Failed to save',
|
||||
'places.duplicateExists': "'{name}' is already in this trip.",
|
||||
'places.addAnyway': 'Add anyway',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'No se pudo crear la categoría',
|
||||
'places.nameRequired': 'Introduce un nombre',
|
||||
'places.saveError': 'No se pudo guardar',
|
||||
'places.duplicateExists': "'{name}' ya está en este viaje.",
|
||||
'places.addAnyway': 'Añadir de todos modos',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -89,5 +89,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Impossible de créer la catégorie',
|
||||
'places.nameRequired': 'Veuillez saisir un nom',
|
||||
'places.saveError': "Échec de l'enregistrement",
|
||||
'places.duplicateExists': "'{name}' est déjà dans ce voyage.",
|
||||
'places.addAnyway': 'Ajouter quand même',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -90,5 +90,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Αποτυχία δημιουργίας κατηγορίας',
|
||||
'places.nameRequired': 'Παρακαλώ εισαγάγετε ένα όνομα',
|
||||
'places.saveError': 'Αποτυχία αποθήκευσης',
|
||||
'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.",
|
||||
'places.addAnyway': 'Προσθήκη ούτως ή άλλως',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -89,5 +89,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát',
|
||||
'places.nameRequired': 'Kérjük, adj meg egy nevet',
|
||||
'places.saveError': 'Nem sikerült menteni',
|
||||
'places.duplicateExists': "A(z) '{name}' már szerepel ebben az utazásban.",
|
||||
'places.addAnyway': 'Hozzáadás mindenképp',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Gagal membuat kategori',
|
||||
'places.nameRequired': 'Harap masukkan nama',
|
||||
'places.saveError': 'Gagal menyimpan',
|
||||
'places.duplicateExists': "'{name}' sudah ada di perjalanan ini.",
|
||||
'places.addAnyway': 'Tetap tambahkan',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Impossibile creare la categoria',
|
||||
'places.nameRequired': 'Inserisci un nome',
|
||||
'places.saveError': 'Impossibile salvare',
|
||||
'places.duplicateExists': "'{name}' è già in questo viaggio.",
|
||||
'places.addAnyway': 'Aggiungi comunque',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -89,5 +89,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'カテゴリの作成に失敗しました',
|
||||
'places.nameRequired': '名前を入力してください',
|
||||
'places.saveError': '保存に失敗しました',
|
||||
'places.duplicateExists': '「{name}」はすでにこの旅程に含まれています。',
|
||||
'places.addAnyway': 'それでも追加',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -86,5 +86,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': '카테고리 생성 실패',
|
||||
'places.nameRequired': '이름을 입력하세요',
|
||||
'places.saveError': '저장 실패',
|
||||
'places.duplicateExists': "'{name}'은(는) 이미 이 여행에 있습니다.",
|
||||
'places.addAnyway': '그래도 추가',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -89,5 +89,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Categorie aanmaken mislukt',
|
||||
'places.nameRequired': 'Voer een naam in',
|
||||
'places.saveError': 'Opslaan mislukt',
|
||||
'places.duplicateExists': "'{name}' staat al in deze reis.",
|
||||
'places.addAnyway': 'Toch toevoegen',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -78,6 +78,8 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Nie udało się utworzyć kategorii',
|
||||
'places.nameRequired': 'Proszę podać nazwę',
|
||||
'places.saveError': 'Nie udało się zapisać',
|
||||
'places.duplicateExists': "'{name}' jest już w tej podróży.",
|
||||
'places.addAnyway': 'Dodaj mimo to',
|
||||
'places.importNaverList': 'Lista Naver',
|
||||
'places.importList': 'Import listy',
|
||||
'places.googleListHint': 'Wklej link do listy Google Maps.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Не удалось создать категорию',
|
||||
'places.nameRequired': 'Введите название',
|
||||
'places.saveError': 'Ошибка сохранения',
|
||||
'places.duplicateExists': "'{name}' уже есть в этой поездке.",
|
||||
'places.addAnyway': 'Всё равно добавить',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -87,5 +87,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Kategori oluşturulamadı',
|
||||
'places.nameRequired': 'Lütfen bir ad girin',
|
||||
'places.saveError': 'Kaydedilemedi',
|
||||
'places.duplicateExists': "'{name}' zaten bu gezide var.",
|
||||
'places.addAnyway': 'Yine de ekle',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -88,5 +88,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': 'Не вдалося створити категорію',
|
||||
'places.nameRequired': 'Введіть назву',
|
||||
'places.saveError': 'Помилка збереження',
|
||||
'places.duplicateExists': "'{name}' вже є в цій подорожі.",
|
||||
'places.addAnyway': 'Все одно додати',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -83,5 +83,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': '建立分類失敗',
|
||||
'places.nameRequired': '請輸入名稱',
|
||||
'places.saveError': '儲存失敗',
|
||||
'places.duplicateExists': "'{name}' 已在此行程中。",
|
||||
'places.addAnyway': '仍要新增',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -83,5 +83,7 @@ const places: TranslationStrings = {
|
||||
'places.categoryCreateError': '创建分类失败',
|
||||
'places.nameRequired': '请输入名称',
|
||||
'places.saveError': '保存失败',
|
||||
'places.duplicateExists': "'{name}' 已在此行程中。",
|
||||
'places.addAnyway': '仍然添加',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -58,6 +58,7 @@ git rebase upstream/dev # or: git merge upstream/dev
|
||||
Working on a dedicated branch keeps your changes isolated and makes PRs easier to review:
|
||||
|
||||
```bash
|
||||
# Create a new branch off of dev
|
||||
git checkout -b fix/my-changes origin/dev
|
||||
```
|
||||
|
||||
@@ -70,16 +71,10 @@ Branch naming conventions:
|
||||
|
||||
## 5. Install Dependencies
|
||||
|
||||
Install dependencies for both the client and server:
|
||||
The repo is an npm workspace monorepo. One command at the root installs everything:
|
||||
|
||||
```bash
|
||||
# Client
|
||||
cd client
|
||||
npm i
|
||||
|
||||
# Server
|
||||
cd ../server
|
||||
npm i
|
||||
npm ci
|
||||
```
|
||||
|
||||
---
|
||||
@@ -127,31 +122,70 @@ You can override `KITINERARY_EXTRACTOR_PATH` if you installed the binary to a di
|
||||
|
||||
## 7. Available Scripts
|
||||
|
||||
### Root (`/`)
|
||||
|
||||
These commands run across all workspaces at once and are the recommended way to work:
|
||||
|
||||
| Command | Description |
|
||||
|----------------------|---------------------------------------------------------------------|
|
||||
| `npm run dev` | Build shared, then start shared (watch), server, and client together via `concurrently` |
|
||||
| `npm run build` | Build shared → server → client in order |
|
||||
| `npm test` | Run tests in shared, server, and client |
|
||||
| `npm run test:cov` | Run coverage for server and client |
|
||||
| `npm run test:e2e` | Run end-to-end tests (server) |
|
||||
| `npm run lint` | Lint shared, server, and client |
|
||||
| `npm run format` | Format shared, server, and client |
|
||||
| `npm run format:check` | Check formatting across all workspaces |
|
||||
|
||||
### Shared (`/shared`)
|
||||
|
||||
The `@trek/shared` package is the single source of truth for code shared between the client and server. It holds the **Zod schemas that define the API contracts** (request/response shapes, common primitives, pagination) and the **i18n translation layer** (per-language keys and types). Both workspaces import from it, so schema and translation changes propagate to both sides from one place.
|
||||
|
||||
> **Tip:** run `npm run i18n:parity` (or `i18n:parity:strict`) in this package to verify every locale exposes the same translation keys — the CI parity gate runs the strict variant.
|
||||
|
||||
| Command | Description |
|
||||
|-----------------------------|--------------------------------------|
|
||||
| `npm run build` | Compile shared package (tsup) |
|
||||
| `npm run build:watch` | Compile in watch mode |
|
||||
| `npm test` | Run tests |
|
||||
| `npm run typecheck` | Type-check without emitting |
|
||||
| `npm run i18n:parity` | Check locale key parity |
|
||||
| `npm run i18n:parity:strict`| Strict locale key parity (CI gate) |
|
||||
| `npm run lint` | Lint source |
|
||||
| `npm run format` | Format source |
|
||||
|
||||
### Server (`/server`)
|
||||
|
||||
| Command | Description |
|
||||
|----------------------------|------------------------------------------|
|
||||
| `npm start` | Start the server (production) |
|
||||
| `npm run dev` | Start the server in watch mode (tsx) |
|
||||
| `npm run dev` | Start the server in watch mode |
|
||||
| `npm run build` | Compile server |
|
||||
| `npm run typecheck` | Type-check without emitting |
|
||||
| `npm test` | Run all tests |
|
||||
| `npm run test:unit` | Run unit tests only |
|
||||
| `npm run test:integration` | Run integration tests |
|
||||
| `npm run test:ws` | Run WebSocket tests |
|
||||
| `npm run test:e2e` | Run end-to-end tests |
|
||||
| `npm run test:watch` | Run tests in watch mode |
|
||||
| `npm run test:coverage` | Run tests with coverage report |
|
||||
| `npm run lint` | Lint source |
|
||||
| `npm run format` | Format source |
|
||||
|
||||
### Client (`/client`)
|
||||
|
||||
| Command | Description |
|
||||
|--------------------------|------------------------------------------------------|
|
||||
| `npm run dev` | Start the Vite dev server |
|
||||
| `npm run build` | Build for production (runs icon generation first) |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` | Run all tests |
|
||||
| `npm run test:unit` | Run unit tests only |
|
||||
| `npm run test:integration` | Run integration tests |
|
||||
| `npm run test:watch` | Run tests in watch mode |
|
||||
| `npm run test:coverage` | Run tests with coverage report |
|
||||
| Command | Description |
|
||||
|----------------------------|------------------------------------------------------|
|
||||
| `npm run dev` | Start the Vite dev server |
|
||||
| `npm run build` | Build for production (runs icon generation first) |
|
||||
| `npm run preview` | Preview the production build locally |
|
||||
| `npm test` | Run all tests |
|
||||
| `npm run test:unit` | Run unit tests only |
|
||||
| `npm run test:integration` | Run integration tests |
|
||||
| `npm run test:watch` | Run tests in watch mode |
|
||||
| `npm run test:coverage` | Run tests with coverage report |
|
||||
| `npm run lint` | Lint source |
|
||||
| `npm run format` | Format source |
|
||||
|
||||
---
|
||||
|
||||
@@ -162,7 +196,7 @@ git add .
|
||||
git commit -m "fix: describe your change"
|
||||
|
||||
# Push to your fork's dev branch
|
||||
git push origin fix/my-changes:dev
|
||||
git push origin fix/my-changes
|
||||
|
||||
# Or if working directly on dev
|
||||
git push origin dev
|
||||
@@ -175,5 +209,5 @@ Then open a Pull Request from your fork to `mauriceboe/TREK` targeting the `dev`
|
||||
## Tips
|
||||
|
||||
- Always branch off from an up-to-date `dev` — run `git fetch upstream && git rebase upstream/dev` before starting new work.
|
||||
- Run tests before pushing: `npm run test` in both `client/` and `server/`.
|
||||
- Run tests before pushing: `npm test` at the repo root runs all workspaces.
|
||||
- Follow the commit message conventions described in the [[Contributing]] guidelines.
|
||||
Reference in New Issue
Block a user