diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 387c924d..20f1864b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,20 @@ on: - '.github/workflows/test.yml' jobs: + i18n-parity: + name: i18n Key Parity + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Check i18n key parity + run: node shared/scripts/i18n-parity.mjs --strict + shared-contracts: name: Shared Contracts (Zod) runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index a4b5444b..ea8c44fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ COPY server/ ./server/ RUN npm run build --workspace=server # ── Stage 4: production runtime ────────────────────────────────────────────── -FROM node:24-alpine +FROM node:24-trixie-slim WORKDIR /app # Workspace manifests only — source never enters this stage. @@ -39,11 +39,33 @@ COPY package.json package-lock.json ./ COPY shared/package.json ./shared/ COPY server/package.json ./server/ -# better-sqlite3 native addon requires build tools; purged after install. -RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \ +# better-sqlite3 native addon requires build tools (purged after compile). +# kitinerary-extractor for booking-confirmation import: +# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck) +# arm64 — apt package (KDE publishes no arm64 static binary) +RUN apt-get update && \ + apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \ npm ci --workspace=server --omit=dev && \ - apk del python3 make g++ && \ - rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx + ARCH=$(dpkg --print-architecture) && \ + if [ "$ARCH" = "amd64" ]; then \ + wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.0.tgz && \ + echo "b7058d98990053c7b61847fef0c21e02d59b60e323e2b171ca210b682334e801 /tmp/ki.tgz" | sha256sum -c && \ + tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \ + rm /tmp/ki.tgz; \ + else \ + apt-get install -y --no-install-recommends libkitinerary-bin && \ + ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \ + fi && \ + apt-get purge -y python3 build-essential && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx + +ENV XDG_CACHE_HOME=/tmp/kf6-cache +# Prevent Qt from probing for a display in headless containers. +ENV QT_QPA_PLATFORM=offscreen +# Fixed path for both amd64 (static binary) and arm64 (symlink to apt binary). +# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere. +ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor COPY --from=server-builder /app/server/dist ./server/dist # tsconfig-paths/register reads this at runtime to resolve MCP SDK paths. @@ -69,4 +91,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ ENTRYPOINT ["dumb-init", "--"] # cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly. -CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"] +CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"] diff --git a/README.md b/README.md index b1b68317..463cd8d7 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa #### 🧳 Travel management -- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files +- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary)) - **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency - **Packing lists** — categories, templates, user assignment, progress tracking - **Bag tracking** — optional weight tracking with iOS-style distribution diff --git a/client/src/api/client.ts b/client/src/api/client.ts index d66c2503..8e7ffee2 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -38,6 +38,9 @@ import { type CreateTagRequest, type UpdateTagRequest, type CreateCategoryRequest, type UpdateCategoryRequest, type PlaceImportListRequest, + type BookingImportPreviewItem, + type BookingImportPreviewResponse, + type BookingImportConfirmResponse, } from '@trek/shared' import { getSocketId } from './websocket' import { isReachable, probeNow } from '../sync/connectivity' @@ -577,6 +580,17 @@ export const reservationsApi = { update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data), + importBookingPreview: (tripId: number | string, files: File[]): Promise => { + const fd = new FormData() + for (const f of files) fd.append('files', f) + return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + }, + importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise => + apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data), +} + +export const healthApi = { + features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data), } export const weatherApi = { diff --git a/client/src/components/Planner/BookingImportModal.tsx b/client/src/components/Planner/BookingImportModal.tsx new file mode 100644 index 00000000..c7e02631 --- /dev/null +++ b/client/src/components/Planner/BookingImportModal.tsx @@ -0,0 +1,382 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { useState, useRef, useEffect } from 'react' +import { Upload, Plane, Train, Hotel, UtensilsCrossed, Car, Anchor, Calendar, ArrowLeft, X } from 'lucide-react' +import type { BookingImportPreviewItem } from '@trek/shared' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import { reservationsApi } from '../../api/client' +import { useTripStore } from '../../store/tripStore' + +interface BookingImportModalProps { + isOpen: boolean + onClose: () => void + tripId: number + pushUndo?: (label: string, undoFn: () => Promise | void) => void +} + +const ACCEPTED_EXTS = ['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt'] +const MAX_FILE_BYTES = 10 * 1024 * 1024 +const MAX_FILES = 5 + +const TYPE_ICONS: Record> = { + flight: Plane, + train: Train, + hotel: Hotel, + restaurant: UtensilsCrossed, + car: Car, + cruise: Anchor, + event: Calendar, +} + +function typeColor(type: string): string { + const map: Record = { + flight: '#3b82f6', + train: '#10b981', + hotel: '#8b5cf6', + restaurant: '#f59e0b', + car: '#6b7280', + cruise: '#06b6d4', + event: '#ec4899', + } + return map[type] ?? 'var(--text-faint)' +} + +function formatDateTime(iso: unknown): string { + if (!iso) return '' + const str = typeof iso === 'string' ? iso : typeof iso === 'object' ? JSON.stringify(iso) : String(iso) + const date = str.slice(0, 10) + const time = str.length > 10 ? str.slice(11, 16) : '' + return [date, time].filter(Boolean).join(' ') +} + +export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }: BookingImportModalProps) { + const { t } = useTranslation() + const toast = useToast() + const loadTrip = useTripStore((s) => s.loadTrip) + const fileInputRef = useRef(null) + const mouseDownTarget = useRef(null) + + type Phase = 'upload' | 'preview' | 'confirming' + const [phase, setPhase] = useState('upload') + const [files, setFiles] = useState([]) + const [isDragOver, setIsDragOver] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [previewItems, setPreviewItems] = useState([]) + const [warnings, setWarnings] = useState([]) + const [excluded, setExcluded] = useState>(() => new Set()) + + const reset = () => { + setPhase('upload') + setFiles([]) + setIsDragOver(false) + setLoading(false) + setError('') + setPreviewItems([]) + setWarnings([]) + setExcluded(new Set()) + } + + useEffect(() => { + if (isOpen) reset() + // reset is stable — intentional + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]) + + const handleClose = () => { reset(); onClose() } + + const validateFile = (f: File): string | null => { + const ext = ('.' + f.name.toLowerCase().split('.').pop()) as string + if (!ACCEPTED_EXTS.includes(ext)) return t('reservations.import.unsupportedFormat') + if (f.size > MAX_FILE_BYTES) return t('reservations.import.fileTooLarge', { name: f.name }) + return null + } + + const selectFiles = (incoming: File[]) => { + const valid: File[] = [] + let firstErr: string | null = null + for (const f of incoming.slice(0, MAX_FILES)) { + const err = validateFile(f) + if (err) { firstErr = firstErr ?? err; continue } + valid.push(f) + } + if (valid.length === 0) { setError(firstErr ?? ''); return } + setFiles(valid) + setError(firstErr ?? '') + } + + const handleInputChange = (e: React.ChangeEvent) => { + const list = e.target.files ? Array.from(e.target.files) : [] + e.target.value = '' + if (list.length) selectFiles(list) + } + + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true) } + const handleDragLeave = (e: React.DragEvent) => { if (e.target === e.currentTarget) setIsDragOver(false) } + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + const list = Array.from(e.dataTransfer.files) + if (list.length) selectFiles(list) + } + + const handleParse = async () => { + if (files.length === 0 || loading) return + setLoading(true) + setError('') + try { + const result = await reservationsApi.importBookingPreview(tripId, files) + setPreviewItems(result.items ?? []) + setWarnings(result.warnings ?? []) + setExcluded(new Set()) + setPhase('preview') + } catch (err: any) { + const msg = err?.response?.data?.error ?? t('reservations.import.error') + setError(msg) + } finally { + setLoading(false) + } + } + + const handleConfirm = async () => { + const toImport = previewItems.filter((_, i) => !excluded.has(i)) + if (toImport.length === 0) return + setPhase('confirming') + setError('') + try { + const result = await reservationsApi.importBookingConfirm(tripId, toImport) + const created = result.created ?? [] + await loadTrip(tripId) + + if (created.length > 0) { + pushUndo?.(t('undo.importBooking'), async () => { + try { + const { reservationsApi: rApi } = await import('../../api/client') + await Promise.all(created.map((r) => rApi.delete(tripId, r.id).catch(() => {}))) + } catch {} + await loadTrip(tripId) + }) + toast.success(t('reservations.import.success', { count: created.length })) + } else { + toast.warning(t('reservations.import.previewEmpty')) + } + + handleClose() + } catch (err: any) { + setError(err?.response?.data?.error ?? t('reservations.import.error')) + setPhase('preview') + } + } + + const toggleExclude = (idx: number) => { + setExcluded(prev => { + const next = new Set(prev) + if (next.has(idx)) next.delete(idx); else next.add(idx) + return next + }) + } + + const activeCount = previewItems.filter((_, i) => !excluded.has(i)).length + + if (!isOpen) return null + + return ReactDOM.createPortal( +
{ mouseDownTarget.current = e.target }} + onClick={e => { + if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose() + mouseDownTarget.current = null + }} + > +
e.stopPropagation()} + className="bg-surface-card" + style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }} + > + {/* Header */} +
+ {phase === 'preview' && ( + + )} +
+ {t('reservations.import.title')} +
+ +
+ +
+ {/* Upload phase */} + {phase === 'upload' && ( + <> +
+ {t('reservations.import.acceptedFormats')} +
+ + + +
fileInputRef.current?.click()} + onDragOver={handleDragOver} + onDragEnter={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'} + style={{ + width: '100%', minHeight: 100, borderRadius: 12, + border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`, + display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', + gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', + marginBottom: 12, padding: 16, boxSizing: 'border-box', + transition: 'border-color 0.15s, background 0.15s', + }} + > + + {isDragOver ? ( + {t('reservations.import.dropActive')} + ) : files.length > 0 ? ( + {files.map(f => f.name).join(', ')} + ) : ( + {t('reservations.import.dropHere')} + )} +
+ + )} + + {/* Preview phase */} + {(phase === 'preview' || phase === 'confirming') && ( + <> +
+ {t('reservations.import.previewHeading', { count: previewItems.length })} +
+ + {previewItems.length === 0 && ( +
+ {t('reservations.import.previewEmpty')} +
+ )} + + {previewItems.map((item, idx) => { + const Icon = TYPE_ICONS[item.type] ?? Calendar + const isExcluded = excluded.has(idx) + const fromEp = item.endpoints?.find(e => e.role === 'from') + const toEp = item.endpoints?.find(e => e.role === 'to') + + return ( +
+
+ +
+
+
+ {item.title} +
+ {fromEp && toEp && ( +
+ {fromEp.code ?? fromEp.name} → {toEp.code ?? toEp.name} +
+ )} + {item.reservation_time && ( +
+ {formatDateTime(item.reservation_time)} + {item.reservation_end_time && ` – ${formatDateTime(item.reservation_end_time)}`} +
+ )} + {item._accommodation?.check_in && ( +
+ {formatDateTime(item._accommodation.check_in)} – {formatDateTime(item._accommodation.check_out)} +
+ )} + {item.confirmation_number && ( +
+ {item.confirmation_number} +
+ )} +
+ +
+ ) + })} + + )} + + {/* Warnings */} + {warnings.length > 0 && ( +
+ {warnings.join('\n')} +
+ )} + + {/* Error */} + {error && ( +
+ {error} +
+ )} +
+ + {/* Footer */} +
+ + + {phase === 'upload' && ( + + )} + + {(phase === 'preview' || phase === 'confirming') && ( + + )} +
+
+
, + document.body + ) +} diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 5a7b980c..1d428d6f 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n' import { Plane, Hotel, Utensils, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Ticket, FileText, MapPin, Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users, - ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, + ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, Download, } from 'lucide-react' import { openFile } from '../../utils/fileDownload' import Markdown from 'react-markdown' @@ -468,6 +468,8 @@ interface ReservationsPanelProps { assignments: AssignmentsMap files?: TripFile[] onAdd: () => void + onImport?: () => void + bookingImportAvailable?: boolean onEdit: (reservation: Reservation) => void onDelete: (id: number) => void onNavigateToFiles: () => void @@ -475,7 +477,7 @@ interface ReservationsPanelProps { addManualKey?: string } -export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) { +export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) { const { t, locale } = useTranslation() const can = useCanDo() const trip = useTripStore((s) => s.trip) @@ -582,20 +584,35 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme )} {canEdit && ( - +
+ {onImport && bookingImportAvailable && ( + + )} + +
)} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index aaff6778..af7637f5 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -16,6 +16,7 @@ import SlidingTabs from '../components/shared/SlidingTabs' import TripMembersModal from '../components/Trips/TripMembersModal' import { ReservationModal } from '../components/Planner/ReservationModal' import { TransportModal } from '../components/Planner/TransportModal' +import BookingImportModal from '../components/Planner/BookingImportModal' // MemoriesPanel moved to Journey addon import ReservationsPanel from '../components/Planner/ReservationsPanel' import PackingListPanel from '../components/Packing/PackingListPanel' @@ -182,6 +183,7 @@ export default function TripPlannerPage(): React.ReactElement | null { prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId, showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, showReservationModal, setShowReservationModal, editingReservation, setEditingReservation, + showBookingImport, setShowBookingImport, bookingImportAvailable, bookingForAssignmentId, setBookingForAssignmentId, showTransportModal, setShowTransportModal, editingTransport, setEditingTransport, transportModalDayId, setTransportModalDayId, @@ -628,6 +630,8 @@ export default function TripPlannerPage(): React.ReactElement | null { assignments={assignments} files={files} onAdd={() => { setEditingReservation(null); setShowReservationModal(true) }} + onImport={() => setShowBookingImport(true)} + bookingImportAvailable={bookingImportAvailable} onEdit={(r) => { setEditingReservation(r); setShowReservationModal(true) }} onDelete={handleDeleteReservation} onNavigateToFiles={() => handleTabChange('dateien')} @@ -676,6 +680,7 @@ export default function TripPlannerPage(): React.ReactElement | null { setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} /> {showTransportModal && { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />} + setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} /> setDeletePlaceId(null)} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index e1a6c5b0..be26c284 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -7,7 +7,7 @@ import { getCached, fetchPhoto } from '../../services/photoService' import { useToast } from '../../components/shared/Toast' import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react' import { useTranslation } from '../../i18n' -import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../../api/client' +import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi } from '../../api/client' import { accommodationRepo } from '../../repo/accommodationRepo' import { offlineDb } from '../../db/offlineDb' import { useAuthStore } from '../../store/authStore' @@ -138,6 +138,8 @@ export function useTripPlanner() { const [showMembersModal, setShowMembersModal] = useState(false) const [showReservationModal, setShowReservationModal] = useState(false) const [editingReservation, setEditingReservation] = useState(null) + const [showBookingImport, setShowBookingImport] = useState(false) + const [bookingImportAvailable, setBookingImportAvailable] = useState(false) const [bookingForAssignmentId, setBookingForAssignmentId] = useState(null) const [showTransportModal, setShowTransportModal] = useState(false) const [editingTransport, setEditingTransport] = useState(null) @@ -163,6 +165,10 @@ export function useTripPlanner() { setFitKey(k => k + 1) }, [trip, places]) + useEffect(() => { + healthApi.features().then(f => setBookingImportAvailable(f.bookingImport)).catch(() => {}) + }, []) + const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null const [visibleConnections, setVisibleConnections] = useState(() => { if (typeof window === 'undefined' || !connectionsStorageKey) return [] @@ -624,6 +630,7 @@ export function useTripPlanner() { prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId, showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, showReservationModal, setShowReservationModal, editingReservation, setEditingReservation, + showBookingImport, setShowBookingImport, bookingImportAvailable, bookingForAssignmentId, setBookingForAssignmentId, showTransportModal, setShowTransportModal, editingTransport, setEditingTransport, transportModalDayId, setTransportModalDayId, diff --git a/docker-compose.yml b/docker-compose.yml index a72cbecd..83a173be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: app: - image: mauriceboe/trek:latest + image: mauriceboe/trek:dev container_name: trek read_only: true security_opt: @@ -12,7 +12,7 @@ services: - SETUID - SETGID tmpfs: - - /tmp:noexec,nosuid,size=64m + - /tmp:noexec,nosuid,size=128m ports: - "3000:3000" environment: @@ -42,6 +42,7 @@ services: # - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist # - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300) # - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20) +# - KITINERARY_EXTRACTOR_PATH= # Optional. Full path to kitinerary-extractor binary. Auto-detected from PATH and /usr/lib/*/libexec/kf6/ when unset. volumes: - ./data:/app/data - ./uploads:/app/uploads diff --git a/package-lock.json b/package-lock.json index a124db8b..be708800 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5209,7 +5209,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 45922464..977bedcc 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -29,6 +29,7 @@ import { JourneyModule } from './journey/journey.module'; import { ShareModule } from './share/share.module'; import { SettingsModule } from './settings/settings.module'; import { BackupModule } from './backup/backup.module'; +import { BookingImportModule } from './booking-import/booking-import.module'; import { AuthModule } from './auth/auth.module'; import { OidcModule } from './oidc/oidc.module'; import { OauthModule } from './oauth/oauth.module'; @@ -43,7 +44,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * (weather, notifications, ...) 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], + 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], controllers: [HealthController], providers: [ HealthService, diff --git a/server/src/nest/booking-import/booking-import.controller.ts b/server/src/nest/booking-import/booking-import.controller.ts new file mode 100644 index 00000000..6d20b3aa --- /dev/null +++ b/server/src/nest/booking-import/booking-import.controller.ts @@ -0,0 +1,102 @@ +import { + Controller, + Post, + Body, + Param, + Headers, + HttpException, + UseGuards, + UseInterceptors, + UploadedFiles, +} from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import type { User } from '../../types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { BookingImportService } from './booking-import.service'; +import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse } from '@trek/shared'; + +const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']); +const MAX_FILE_BYTES = 10 * 1024 * 1024; +const MAX_FILES = 5; + +const UPLOAD = { + storage: memoryStorage(), + limits: { fileSize: MAX_FILE_BYTES, files: MAX_FILES }, +}; + +@Controller('api/trips/:tripId/reservations/import') +@UseGuards(JwtAuthGuard) +export class BookingImportController { + constructor(private readonly bookingImport: BookingImportService) {} + + private requireTrip(tripId: string, user: User) { + const trip = this.bookingImport.verifyTripAccess(tripId, user.id); + if (!trip) throw new HttpException({ error: 'Trip not found' }, 404); + return trip; + } + + private requireEdit(trip: ReturnType, user: User): void { + if (!this.bookingImport.canEdit(trip!, user)) { + throw new HttpException({ error: 'No permission' }, 403); + } + } + + /** + * POST /api/trips/:tripId/reservations/import/booking + * Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT). + * Returns a preview list without persisting anything. + */ + @Post('booking') + @UseInterceptors(FilesInterceptor('files', MAX_FILES, UPLOAD)) + async preview( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @UploadedFiles() files: Express.Multer.File[] | undefined, + ) { + const trip = this.requireTrip(tripId, user); + this.requireEdit(trip, user); + + if (!this.bookingImport.isAvailable()) { + throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503); + } + + if (!files || files.length === 0) { + throw new HttpException({ error: 'No files uploaded' }, 400); + } + + // Validate extensions + for (const f of files) { + const ext = f.originalname.toLowerCase().slice(f.originalname.lastIndexOf('.')); + if (!ACCEPTED_EXTS.has(ext)) { + throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400); + } + } + + const result: BookingImportPreviewResponse = await this.bookingImport.preview(files); + return result; + } + + /** + * POST /api/trips/:tripId/reservations/import/booking/confirm + * Persists the user-confirmed subset of parsed items. + */ + @Post('booking/confirm') + async confirm( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Body() body: { items?: BookingImportPreviewItem[] }, + @Headers('x-socket-id') socketId?: string, + ): Promise { + const trip = this.requireTrip(tripId, user); + this.requireEdit(trip, user); + + const items = body?.items; + if (!Array.isArray(items) || items.length === 0) { + throw new HttpException({ error: 'items must be a non-empty array' }, 400); + } + + return this.bookingImport.confirm(tripId, items, socketId); + } +} diff --git a/server/src/nest/booking-import/booking-import.module.ts b/server/src/nest/booking-import/booking-import.module.ts new file mode 100644 index 00000000..332bcd32 --- /dev/null +++ b/server/src/nest/booking-import/booking-import.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { BookingImportController } from './booking-import.controller'; +import { BookingImportService } from './booking-import.service'; +import { KitineraryExtractorService } from './kitinerary-extractor.service'; +import { FeaturesController } from './features.controller'; + +@Module({ + controllers: [BookingImportController, FeaturesController], + providers: [BookingImportService, KitineraryExtractorService], +}) +export class BookingImportModule {} diff --git a/server/src/nest/booking-import/booking-import.service.ts b/server/src/nest/booking-import/booking-import.service.ts new file mode 100644 index 00000000..68cd5b91 --- /dev/null +++ b/server/src/nest/booking-import/booking-import.service.ts @@ -0,0 +1,165 @@ +import { Injectable, HttpException } from '@nestjs/common'; +import { broadcast } from '../../websocket'; +import { checkPermission } from '../../services/permissions'; +import { verifyTripAccess } from '../../services/tripAccess'; +import { createReservation } from '../../services/reservationService'; +import { createPlace } from '../../services/placeService'; +import { searchNominatim } from '../../services/mapsService'; +import { db } from '../../db/database'; +import type { User } from '../../types'; +import { KitineraryExtractorService } from './kitinerary-extractor.service'; +import { mapReservations } from './kitinerary-mapper'; +import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, Reservation } from '@trek/shared'; +import type { ParsedBookingItem } from './kitinerary.types'; + +function resolveDayId(tripId: string, iso: string | null | undefined): number | null { + if (!iso) return null; + const date = iso.slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null; + const row = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined; + return row?.id ?? null; +} + +@Injectable() +export class BookingImportService { + constructor(private readonly extractor: KitineraryExtractorService) {} + + isAvailable(): boolean { + return this.extractor.isAvailable(); + } + + verifyTripAccess(tripId: string, userId: number) { + return verifyTripAccess(tripId, userId); + } + + canEdit(trip: NonNullable>, user: User): boolean { + return checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id); + } + + /** + * Parse uploaded files through kitinerary-extractor and return a preview list. + * Does NOT persist anything. + */ + async preview(files: Express.Multer.File[]): Promise { + if (!this.extractor.isAvailable()) { + throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503); + } + + const allItems: ParsedBookingItem[] = []; + const allWarnings: string[] = []; + + for (const file of files) { + let kiItems; + try { + kiItems = await this.extractor.extract(file.buffer, file.originalname); + } catch (err) { + allWarnings.push(`${file.originalname}: extraction failed — ${err instanceof Error ? err.message : String(err)}`); + continue; + } + + if (kiItems.length === 0) { + allWarnings.push(`${file.originalname}: no reservations found`); + continue; + } + + const { items, warnings } = mapReservations(kiItems, file.originalname); + allItems.push(...items); + allWarnings.push(...warnings); + } + + return { items: allItems, warnings: allWarnings }; + } + + /** + * Persist a confirmed list of parsed items. + * Creates place rows for hotel/restaurant/event venues, then calls createReservation. + * Broadcasts reservation:created (and accommodation:created if applicable) per item. + */ + async confirm( + tripId: string, + items: BookingImportPreviewItem[], + socketId: string | undefined, + ): Promise { + const created: Reservation[] = []; + + for (const item of items) { + try { + const { _venue, _accommodation, source: _src, ...reservationData } = item; + + // Auto-create a place row for venue-based reservations + let placeId: number | undefined; + if (_venue?.name) { + // Geocode before creating so the broadcast carries the coordinates + let lat = _venue.lat; + let lng = _venue.lng; + if (lat == null && (_venue.address || _venue.name)) { + try { + const queries = [ + _venue.address ? `${_venue.name} ${_venue.address}` : null, + _venue.address ?? null, + _venue.name, + ].filter((q): q is string => !!q); + + for (const q of queries) { + const results = await searchNominatim(q); + const hit = results[0]; + if (hit?.lat != null && hit?.lng != null) { + lat = hit.lat; + lng = hit.lng; + break; + } + } + } catch { + // geocoding failure is non-fatal + } + } + + const place = createPlace(tripId, { + name: _venue.name, + lat, + lng, + address: _venue.address, + website: _venue.website, + phone: _venue.phone, + }); + placeId = (place as any).id; + broadcast(tripId, 'place:created', { place }, socketId); + } + + // Build create_accommodation for hotel reservations. + // start_day_id / end_day_id are resolved from check-in/out ISO dates so + // the accommodation row is actually inserted (createReservation gates on them). + let createAccommodation: { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_out?: string; confirmation?: string } | undefined; + if (item.type === 'hotel' && _accommodation) { + const startDayId = resolveDayId(tripId, _accommodation.check_in); + const endDayId = resolveDayId(tripId, _accommodation.check_out); + createAccommodation = { + place_id: placeId, + start_day_id: startDayId ?? undefined, + end_day_id: endDayId ?? undefined, + check_in: _accommodation.check_in, + check_out: _accommodation.check_out, + confirmation: _accommodation.confirmation, + }; + } + + const { reservation, accommodationCreated } = createReservation(tripId, { + ...reservationData, + place_id: placeId, + create_accommodation: createAccommodation, + } as any); + + broadcast(tripId, 'reservation:created', { reservation }, socketId); + if (accommodationCreated) { + broadcast(tripId, 'accommodation:created', {}, socketId); + } + + created.push(reservation); + } catch (err) { + console.error(`[booking-import] Failed to create reservation "${item.title}":`, err instanceof Error ? err.message : err); + } + } + + return { created }; + } +} diff --git a/server/src/nest/booking-import/features.controller.ts b/server/src/nest/booking-import/features.controller.ts new file mode 100644 index 00000000..50b3b849 --- /dev/null +++ b/server/src/nest/booking-import/features.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get } from '@nestjs/common'; +import { KitineraryExtractorService } from './kitinerary-extractor.service'; + +/** Exposes server feature flags consumed by the frontend to show/hide optional UI. */ +@Controller('api/health') +export class FeaturesController { + constructor(private readonly extractor: KitineraryExtractorService) {} + + @Get('features') + features() { + return { + bookingImport: this.extractor.isAvailable(), + }; + } +} diff --git a/server/src/nest/booking-import/kitinerary-extractor.service.ts b/server/src/nest/booking-import/kitinerary-extractor.service.ts new file mode 100644 index 00000000..fbb476a0 --- /dev/null +++ b/server/src/nest/booking-import/kitinerary-extractor.service.ts @@ -0,0 +1,104 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { execFile } from 'node:child_process'; +import { existsSync, readdirSync, writeFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, extname } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { execSync } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { KiReservation } from './kitinerary.types'; + +const execFileAsync = promisify(execFile); +const TIMEOUT_MS = 30_000; +const MAX_BUFFER = 5 * 1024 * 1024; + +@Injectable() +export class KitineraryExtractorService implements OnModuleInit { + private binaryPath: string | null = null; + + onModuleInit() { + this.binaryPath = this.findBinary(); + if (this.binaryPath) { + console.log(`[KItinerary] extractor found at: ${this.binaryPath}`); + } else { + console.info('[KItinerary] extractor not found — booking import feature disabled'); + } + } + + isAvailable(): boolean { + return this.binaryPath !== null; + } + + async extract(buffer: Buffer, fileName: string): Promise { + if (!this.binaryPath) { + throw new Error('kitinerary-extractor is not available on this system'); + } + + const ext = extname(fileName).toLowerCase(); + const tmpFile = join(tmpdir(), `trek-ki-${randomUUID()}${ext}`); + + try { + writeFileSync(tmpFile, buffer); + + const { stdout, stderr } = await execFileAsync(this.binaryPath, [tmpFile], { + timeout: TIMEOUT_MS, + maxBuffer: MAX_BUFFER, + }); + + if (stderr?.trim()) { + // Filter expected noise: currency-symbol ambiguity warnings and vendor + // extractor script errors are normal (every matching script is tried; + // most won't match the current document). + const unexpected = stderr + .split('\n') + .filter(l => l.trim()) + .filter(l => !l.includes('Ambig') && !l.includes('JS ERROR') && !l.includes('Invalid result type from script')); + if (unexpected.length) { + console.warn(`[KItinerary] stderr for "${fileName}":`, unexpected.join('\n')); + } + } + + const text = stdout.trim(); + if (!text) return []; + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + console.warn(`[KItinerary] non-JSON output for "${fileName}"`); + return []; + } + + if (Array.isArray(parsed)) return parsed as KiReservation[]; + if (typeof parsed === 'object' && parsed !== null) return [parsed as KiReservation]; + return []; + } finally { + try { unlinkSync(tmpFile); } catch {} + } + } + + private findBinary(): string | null { + const envPath = process.env.KITINERARY_EXTRACTOR_PATH; + if (envPath) { + if (existsSync(envPath)) return envPath; + console.warn(`[KItinerary] KITINERARY_EXTRACTOR_PATH="${envPath}" not found`); + return null; + } + + // Debian/Ubuntu: /usr/lib//libexec/kf6/kitinerary-extractor + try { + for (const dir of readdirSync('/usr/lib')) { + const candidate = join('/usr/lib', dir, 'libexec', 'kf6', 'kitinerary-extractor'); + if (existsSync(candidate)) return candidate; + } + } catch { /* not a Debian system */ } + + // Fallback: binary in PATH + try { + execSync('kitinerary-extractor --version', { stdio: 'pipe', timeout: 3000 }); + return 'kitinerary-extractor'; + } catch { /* not in PATH */ } + + return null; + } +} diff --git a/server/src/nest/booking-import/kitinerary-mapper.ts b/server/src/nest/booking-import/kitinerary-mapper.ts new file mode 100644 index 00000000..a26993a6 --- /dev/null +++ b/server/src/nest/booking-import/kitinerary-mapper.ts @@ -0,0 +1,254 @@ +import { findByIata } from '../../services/airportService'; +import type { + KiReservation, KiFlight, KiTrainTrip, KiBusTrip, KiBoatTrip, + KiLodgingBusiness, KiFoodEstablishment, KiRentalCar, KiEvent, + KiGeo, KiAddress, KiDateTimeish, ParsedBookingItem, ParsedEndpoint, ParsedVenue, +} from './kitinerary.types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Extract a plain ISO string from either a string or a KDE QDateTime object. */ +function toIsoString(dt: KiDateTimeish): string | null { + if (!dt) return null; + if (typeof dt === 'string') return dt || null; + if (typeof dt === 'object' && dt['@type'] === 'QDateTime') return dt['@value'] || null; + return null; +} + +function splitIso(dt: KiDateTimeish): { date: string | null; time: string | null } { + const iso = toIsoString(dt); + if (!iso) return { date: null, time: null }; + return { date: iso.slice(0, 10) || null, time: iso.length > 10 ? iso.slice(11, 16) || null : null }; +} + +function formatAddress(address: string | KiAddress | undefined): string | null { + if (!address) return null; + if (typeof address === 'string') return address || null; + const joined = [address.streetAddress, address.addressLocality, address.postalCode, address.addressCountry].filter(Boolean).join(', '); + return joined || null; +} + +function coords(geo: KiGeo | undefined): { lat: number; lng: number } | null { + if (!geo || geo.latitude == null || geo.longitude == null) return null; + return { lat: Number(geo.latitude), lng: Number(geo.longitude) }; +} + +// --------------------------------------------------------------------------- +// Type mappers +// --------------------------------------------------------------------------- + +function mapFlight(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null { + const f = r.reservationFor as KiFlight | undefined; + if (!f) return null; + + const depIata = f.departureAirport?.iataCode?.toUpperCase() ?? null; + const arrIata = f.arrivalAirport?.iataCode?.toUpperCase() ?? null; + const depAp = depIata ? findByIata(depIata) : null; + const arrAp = arrIata ? findByIata(arrIata) : null; + + const depLabel = depAp ? (depAp.city ? `${depAp.city} (${depAp.iata})` : depAp.name) : (f.departureAirport?.name ?? depIata ?? 'Unknown'); + const arrLabel = arrAp ? (arrAp.city ? `${arrAp.city} (${arrAp.iata})` : arrAp.name) : (f.arrivalAirport?.name ?? arrIata ?? 'Unknown'); + + const airline = f.airline?.name ?? f.airline?.iataCode ?? ''; + const flightNum = f.flightNumber ?? ''; + const title = [airline, flightNum].filter(Boolean).join(' ') || `Flight ${depLabel} → ${arrLabel}`; + + const { date: depDate, time: depTime } = splitIso(f.departureTime); + const { date: arrDate, time: arrTime } = splitIso(f.arrivalTime); + + const endpoints: ParsedEndpoint[] = []; + if (depAp) { + endpoints.push({ role: 'from', sequence: 0, name: depLabel, code: depAp.iata, lat: depAp.lat, lng: depAp.lng, timezone: depAp.tz, local_time: depTime, local_date: depDate }); + } else { + const c = coords(f.departureAirport?.geo); + if (c) endpoints.push({ role: 'from', sequence: 0, name: depLabel, code: depIata, lat: c.lat, lng: c.lng, timezone: null, local_time: depTime, local_date: depDate }); + } + if (arrAp) { + endpoints.push({ role: 'to', sequence: 1, name: arrLabel, code: arrAp.iata, lat: arrAp.lat, lng: arrAp.lng, timezone: arrAp.tz, local_time: arrTime, local_date: arrDate }); + } else { + const c = coords(f.arrivalAirport?.geo); + if (c) endpoints.push({ role: 'to', sequence: 1, name: arrLabel, code: arrIata, lat: c.lat, lng: c.lng, timezone: null, local_time: arrTime, local_date: arrDate }); + } + + return { + type: 'flight', + title, + reservation_time: toIsoString(f.departureTime), + reservation_end_time: toIsoString(f.arrivalTime), + confirmation_number: r.reservationNumber ?? null, + metadata: { + ...(airline ? { airline } : {}), + ...(flightNum ? { flight_number: flightNum } : {}), + ...(depIata ? { departure_airport: depIata } : {}), + ...(arrIata ? { arrival_airport: arrIata } : {}), + }, + endpoints, + needs_review: endpoints.length < 2, + source, + }; +} + +function mapTrain(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null { + const t = r.reservationFor as KiTrainTrip | undefined; + if (!t) return null; + + const depName = t.departureStation?.name ?? 'Unknown'; + const arrName = t.arrivalStation?.name ?? 'Unknown'; + const trainId = t.trainNumber ?? t.trainName ?? ''; + const title = trainId ? `${trainId} (${depName} → ${arrName})` : `Train ${depName} → ${arrName}`; + + const { date: depDate, time: depTime } = splitIso(t.departureTime); + const { date: arrDate, time: arrTime } = splitIso(t.arrivalTime); + + const endpoints: ParsedEndpoint[] = []; + const dc = coords(t.departureStation?.geo); + const ac = coords(t.arrivalStation?.geo); + if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate }); + if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate }); + + return { + type: 'train', + title, + reservation_time: toIsoString(t.departureTime), + reservation_end_time: toIsoString(t.arrivalTime), + confirmation_number: r.reservationNumber ?? null, + metadata: trainId ? { train_number: trainId } : undefined, + endpoints, + needs_review: endpoints.length < 2, + source, + }; +} + +function mapBus(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null { + const b = r.reservationFor as KiBusTrip | undefined; + if (!b) return null; + + const depName = b.departureBusStop?.name ?? 'Unknown'; + const arrName = b.arrivalBusStop?.name ?? 'Unknown'; + const busId = b.busNumber ?? b.busName ?? ''; + const title = busId ? `${busId} (${depName} → ${arrName})` : `Bus ${depName} → ${arrName}`; + + const { date: depDate, time: depTime } = splitIso(b.departureTime); + const { date: arrDate, time: arrTime } = splitIso(b.arrivalTime); + + const endpoints: ParsedEndpoint[] = []; + const dc = coords(b.departureBusStop?.geo); + const ac = coords(b.arrivalBusStop?.geo); + if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate }); + if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate }); + + return { type: 'train', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, needs_review: endpoints.length < 2, source }; +} + +function mapBoat(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null { + const b = r.reservationFor as KiBoatTrip | undefined; + if (!b) return null; + + const depName = b.departureBoatTerminal?.name ?? 'Unknown'; + const arrName = b.arrivalBoatTerminal?.name ?? 'Unknown'; + const title = (b as any).name ?? `Cruise ${depName} → ${arrName}`; + + const { date: depDate, time: depTime } = splitIso(b.departureTime); + const { date: arrDate, time: arrTime } = splitIso(b.arrivalTime); + + const endpoints: ParsedEndpoint[] = []; + const dc = coords(b.departureBoatTerminal?.geo); + const ac = coords(b.arrivalBoatTerminal?.geo); + if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate }); + if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate }); + + return { type: 'cruise', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, source }; +} + +function mapLodging(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null { + const l = r.reservationFor as KiLodgingBusiness | undefined; + if (!l?.name) return null; + + const c = coords(l.geo); + const venue: ParsedVenue = { name: l.name, ...(c ?? {}), address: formatAddress(l.address) ?? undefined, website: l.url ?? undefined, phone: l.telephone ?? undefined }; + + const { date: checkInDate, time: checkInTime } = splitIso(r.checkinTime); + const { date: checkOutDate, time: checkOutTime } = splitIso(r.checkoutTime); + const checkIn = checkInDate ? `${checkInDate}${checkInTime ? `T${checkInTime}` : ''}` : undefined; + const checkOut = checkOutDate ? `${checkOutDate}${checkOutTime ? `T${checkOutTime}` : ''}` : undefined; + + return { + type: 'hotel', + title: l.name, + confirmation_number: r.reservationNumber ?? null, + location: formatAddress(l.address), + _venue: venue, + _accommodation: { check_in: checkIn, check_out: checkOut, confirmation: r.reservationNumber ?? undefined }, + metadata: { ...(checkInTime ? { check_in_time: checkInTime } : {}), ...(checkOutTime ? { check_out_time: checkOutTime } : {}) }, + source, + }; +} + +function mapFood(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null { + const f = r.reservationFor as KiFoodEstablishment | undefined; + if (!f?.name) return null; + + const c = coords(f.geo); + const venue: ParsedVenue = { name: f.name, ...(c ?? {}), address: formatAddress(f.address) ?? undefined, website: f.url ?? undefined, phone: f.telephone ?? undefined }; + + return { type: 'restaurant', title: f.name, reservation_time: toIsoString(r.startTime), reservation_end_time: toIsoString(r.endTime), confirmation_number: r.reservationNumber ?? null, location: formatAddress(f.address), _venue: venue, source }; +} + +function mapRentalCar(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null { + const car = r.reservationFor as KiRentalCar | undefined; + const company = car?.rentalCompany?.name ?? ''; + const carName = car?.name ?? [car?.make, car?.model].filter(Boolean).join(' ') ?? ''; + const title = [company, carName].filter(Boolean).join(' — ') || 'Rental Car'; + + const pickup = r.pickupLocation as KiReservation['pickupLocation']; + const pc = coords(pickup?.geo); + const venue: ParsedVenue | undefined = pickup?.name ? { name: pickup.name, ...(pc ?? {}), address: formatAddress(pickup.address) ?? undefined } : undefined; + + return { type: 'car', title, reservation_time: toIsoString(r.pickupTime), reservation_end_time: toIsoString(r.dropoffTime), confirmation_number: r.reservationNumber ?? null, ...(venue ? { _venue: venue } : {}), source }; +} + +function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null { + const e = r.reservationFor as KiEvent | undefined; + if (!e?.name) return null; + + const loc = e.location; + const c = coords(loc?.geo); + const venue: ParsedVenue | undefined = loc?.name ? { name: loc.name, ...(c ?? {}), address: formatAddress(loc.address) ?? undefined } : undefined; + + return { type: 'event', title: e.name, reservation_time: toIsoString(e.startDate), reservation_end_time: toIsoString(e.endDate), confirmation_number: r.reservationNumber ?? null, location: loc ? (formatAddress(loc.address) ?? loc.name ?? null) : null, ...(venue ? { _venue: venue } : {}), source }; +} + +// --------------------------------------------------------------------------- +// Public +// --------------------------------------------------------------------------- + +export function mapReservations(kiItems: KiReservation[], fileName: string): { items: ParsedBookingItem[]; warnings: string[] } { + const items: ParsedBookingItem[] = []; + const warnings: string[] = []; + + for (let i = 0; i < kiItems.length; i++) { + const r = kiItems[i]; + const source = { fileName, index: i }; + let item: ParsedBookingItem | null = null; + + switch (r['@type']) { + case 'FlightReservation': item = mapFlight(r, source); break; + case 'TrainReservation': item = mapTrain(r, source); break; + case 'BusReservation': item = mapBus(r, source); break; + case 'BoatReservation': item = mapBoat(r, source); break; + case 'LodgingReservation': item = mapLodging(r, source); break; + case 'FoodEstablishmentReservation': item = mapFood(r, source); break; + case 'RentalCarReservation': item = mapRentalCar(r, source); break; + case 'EventReservation': + case 'TouristAttractionVisit': item = mapEvent(r, source); break; + default: + warnings.push(`Unknown type "${r['@type']}" in ${fileName}[${i}] — skipped`); + } + + if (item) items.push(item); + } + + return { items, warnings }; +} diff --git a/server/src/nest/booking-import/kitinerary.types.ts b/server/src/nest/booking-import/kitinerary.types.ts new file mode 100644 index 00000000..b97fcd9e --- /dev/null +++ b/server/src/nest/booking-import/kitinerary.types.ts @@ -0,0 +1,188 @@ +/** KItinerary JSON-LD output types (schema.org subset) */ + +/** KDE's custom date/time wrapper — used when timezone info is present */ +export interface KiDateTime { + '@type': 'QDateTime'; + '@value': string; // ISO 8601 local time (KDE serializes as @value) + timezone?: string; // IANA timezone id +} + +export type KiDateTimeish = string | KiDateTime | null | undefined; + +export interface KiGeo { + '@type'?: string; + latitude?: number; + longitude?: number; +} + +export interface KiAddress { + '@type'?: string; + streetAddress?: string; + addressLocality?: string; + postalCode?: string; + addressCountry?: string; +} + +export interface KiAirport { + '@type'?: string; + name?: string; + iataCode?: string; + geo?: KiGeo; +} + +export interface KiStation { + '@type'?: string; + name?: string; + geo?: KiGeo; +} + +export interface KiBusStop { + '@type'?: string; + name?: string; + geo?: KiGeo; +} + +export interface KiFlight { + '@type'?: string; + flightNumber?: string; + airline?: { name?: string; iataCode?: string }; + departureAirport?: KiAirport; + arrivalAirport?: KiAirport; + departureTime?: KiDateTimeish; + arrivalTime?: KiDateTimeish; +} + +export interface KiTrainTrip { + '@type'?: string; + trainNumber?: string; + trainName?: string; + departureStation?: KiStation; + arrivalStation?: KiStation; + departureTime?: KiDateTimeish; + arrivalTime?: KiDateTimeish; +} + +export interface KiBusTrip { + '@type'?: string; + busNumber?: string; + busName?: string; + departureBusStop?: KiBusStop; + arrivalBusStop?: KiBusStop; + departureTime?: KiDateTimeish; + arrivalTime?: KiDateTimeish; +} + +export interface KiBoatTrip { + '@type'?: string; + name?: string; + departureBoatTerminal?: KiStation; + arrivalBoatTerminal?: KiStation; + departureTime?: KiDateTimeish; + arrivalTime?: KiDateTimeish; +} + +export interface KiLodgingBusiness { + '@type'?: string; + name?: string; + address?: string | KiAddress; + geo?: KiGeo; + telephone?: string; + url?: string; +} + +export interface KiFoodEstablishment { + '@type'?: string; + name?: string; + address?: string | KiAddress; + geo?: KiGeo; + telephone?: string; + url?: string; +} + +export interface KiRentalCar { + '@type'?: string; + name?: string; + model?: string; + make?: string; + rentalCompany?: { name?: string }; +} + +export interface KiEventVenue { + '@type'?: string; + name?: string; + address?: string | KiAddress; + geo?: KiGeo; +} + +export interface KiEvent { + '@type'?: string; + name?: string; + startDate?: KiDateTimeish; + endDate?: KiDateTimeish; + location?: KiEventVenue; +} + +/** A single output node from kitinerary-extractor's JSON array */ +export interface KiReservation { + '@type': string; + reservationNumber?: string; + checkinTime?: KiDateTimeish; + checkoutTime?: KiDateTimeish; + pickupTime?: KiDateTimeish; + dropoffTime?: KiDateTimeish; + startTime?: KiDateTimeish; + endTime?: KiDateTimeish; + reservationFor?: Record; + pickupLocation?: KiEventVenue; + [key: string]: unknown; +} + +/** Endpoint row shape (matches reservation_endpoints table) */ +export interface ParsedEndpoint { + 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; +} + +/** Venue used to auto-create a places row on confirm */ +export interface ParsedVenue { + name: string; + lat?: number; + lng?: number; + address?: string; + website?: string; + phone?: string; +} + +/** Hotel accommodation side-effect data */ +export interface ParsedAccommodation { + check_in?: string; + check_out?: string; + confirmation?: string; +} + +/** + * Parsed reservation preview item — sent to the frontend and passed back on confirm. + * Carries everything createReservation() needs plus _venue / _accommodation for + * server-side side effects, and source for the preview UI. + */ +export interface ParsedBookingItem { + type: string; + title: string; + reservation_time?: string | null; + reservation_end_time?: string | null; + confirmation_number?: string | null; + location?: string | null; + metadata?: Record; + endpoints?: ParsedEndpoint[]; + needs_review?: boolean; + _venue?: ParsedVenue; + _accommodation?: ParsedAccommodation; + source: { fileName: string; index: number }; +} diff --git a/shared/package.json b/shared/package.json index f6bdd088..c91e5ece 100644 --- a/shared/package.json +++ b/shared/package.json @@ -44,7 +44,7 @@ "format:check": "prettier --check \"src/**/*.ts\"", "lint": "eslint --fix \"src/**/*.ts\"", "i18n:parity": "node scripts/i18n-parity.mjs", - "i18n:parity:strict": "node scripts/i18n-parity.mjs --strict --files-only" + "i18n:parity:strict": "node scripts/i18n-parity.mjs --strict" }, "dependencies": { "isomorphic-dompurify": "^3.15.0", diff --git a/shared/src/i18n/ar/reservations.ts b/shared/src/i18n/ar/reservations.ts index 88437682..484085ec 100644 --- a/shared/src/i18n/ar/reservations.ts +++ b/shared/src/i18n/ar/reservations.ts @@ -118,5 +118,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'يجب أن يكون تاريخ/وقت الانتهاء بعد تاريخ/وقت البدء', 'reservations.addBooking': 'إضافة حجز', + 'reservations.import.title': 'استيراد تأكيدات الحجز', + 'reservations.import.cta': 'استيراد من ملف', + 'reservations.import.dropHere': 'أسقط ملفات تأكيد الحجز هنا أو انقر للتحديد', + 'reservations.import.dropActive': 'أسقط الملفات للاستيراد', + 'reservations.import.acceptedFormats': 'المقبول: EML، PDF، PKPass، HTML، TXT (بحد أقصى 10 ميغابايت لكل ملف، حتى 5 ملفات)', + 'reservations.import.parsing': 'جارٍ معالجة الملفات…', + 'reservations.import.previewHeading': 'تم العثور على {count} حجز/حجوزات', + 'reservations.import.previewEmpty': 'تعذّر استخراج أي حجوزات من الملفات المُحمَّلة.', + 'reservations.import.removeItem': 'إزالة', + 'reservations.import.confirm': 'استيراد {count} حجز/حجوزات', + 'reservations.import.back': 'رجوع', + 'reservations.import.success': 'تم استيراد {count} حجز/حجوزات', + 'reservations.import.partialFailure': 'تم استيراد {created}، فشل {failed}', + 'reservations.import.error': 'فشلت المعالجة. تأكد من أن الملف تأكيد حجز صالح.', + 'reservations.import.unavailable': 'استيراد الحجوزات غير متاح على هذا الخادم.', + 'reservations.import.unsupportedFormat': 'صيغة ملف غير مدعومة. استخدم EML أو PDF أو PKPass أو HTML أو TXT.', + 'reservations.import.fileTooLarge': 'الملف "{name}" يتجاوز حد 10 ميغابايت.', }; export default reservations; diff --git a/shared/src/i18n/ar/undo.ts b/shared/src/i18n/ar/undo.ts index e3992b07..8df50bdf 100644 --- a/shared/src/i18n/ar/undo.ts +++ b/shared/src/i18n/ar/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'استيراد خرائط Naver', 'undo.addPlace': 'تمت إضافة المكان', 'undo.done': 'تم التراجع: {action}', + 'undo.importBooking': 'استيراد تأكيد الحجز', }; export default undo; diff --git a/shared/src/i18n/br/reservations.ts b/shared/src/i18n/br/reservations.ts index d51d4658..d6de7ba0 100644 --- a/shared/src/i18n/br/reservations.ts +++ b/shared/src/i18n/br/reservations.ts @@ -119,5 +119,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'A data/hora final deve ser posterior à data/hora inicial', 'reservations.addBooking': 'Adicionar reserva', + 'reservations.import.title': 'Importar confirmações de reserva', + 'reservations.import.cta': 'Importar de arquivo', + 'reservations.import.dropHere': 'Solte os arquivos de confirmação de reserva aqui ou clique para selecionar', + 'reservations.import.dropActive': 'Solte os arquivos para importar', + 'reservations.import.acceptedFormats': 'Aceitos: EML, PDF, PKPass, HTML, TXT (máx. 10 MB cada, até 5 arquivos)', + 'reservations.import.parsing': 'Analisando arquivos…', + 'reservations.import.previewHeading': '{count} reserva(s) encontrada(s)', + 'reservations.import.previewEmpty': 'Nenhuma reserva pôde ser extraída dos arquivos enviados.', + 'reservations.import.removeItem': 'Remover', + 'reservations.import.confirm': 'Importar {count} reserva(s)', + 'reservations.import.back': 'Voltar', + 'reservations.import.success': '{count} reserva(s) importada(s)', + 'reservations.import.partialFailure': '{created} importada(s), {failed} falhou/falharam', + 'reservations.import.error': 'Falha na análise. Verifique se o arquivo é uma confirmação de reserva válida.', + '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.', }; export default reservations; diff --git a/shared/src/i18n/br/undo.ts b/shared/src/i18n/br/undo.ts index b9377e52..788a846a 100644 --- a/shared/src/i18n/br/undo.ts +++ b/shared/src/i18n/br/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Importação do Naver Maps', 'undo.addPlace': 'Local adicionado', 'undo.done': 'Desfeito: {action}', + 'undo.importBooking': 'Importação de confirmação de reserva', }; export default undo; diff --git a/shared/src/i18n/cs/reservations.ts b/shared/src/i18n/cs/reservations.ts index 2a5aa5bc..4fb68a23 100644 --- a/shared/src/i18n/cs/reservations.ts +++ b/shared/src/i18n/cs/reservations.ts @@ -118,5 +118,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'Datum/čas konce musí být po datu/čase začátku', 'reservations.addBooking': 'Přidat rezervaci', + 'reservations.import.title': 'Importovat potvrzení rezervace', + 'reservations.import.cta': 'Importovat ze souboru', + 'reservations.import.dropHere': 'Přetáhněte soubory s potvrzením rezervace sem nebo klikněte pro výběr', + 'reservations.import.dropActive': 'Pusťte soubory pro import', + 'reservations.import.acceptedFormats': 'Přijímané formáty: EML, PDF, PKPass, HTML, TXT (max. 10 MB každý, až 5 souborů)', + 'reservations.import.parsing': 'Zpracování souborů…', + 'reservations.import.previewHeading': 'Nalezeno {count} rezervace/í', + 'reservations.import.previewEmpty': 'Z nahraných souborů se nepodařilo extrahovat žádné rezervace.', + 'reservations.import.removeItem': 'Odebrat', + 'reservations.import.confirm': 'Importovat {count} rezervaci/í', + 'reservations.import.back': 'Zpět', + 'reservations.import.success': '{count} rezervace/í importováno', + 'reservations.import.partialFailure': '{created} importováno, {failed} selhalo', + 'reservations.import.error': 'Zpracování selhalo. Ujistěte se, že soubor je platným potvrzením rezervace.', + '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.', }; export default reservations; diff --git a/shared/src/i18n/cs/undo.ts b/shared/src/i18n/cs/undo.ts index 5aba0b31..51c7331e 100644 --- a/shared/src/i18n/cs/undo.ts +++ b/shared/src/i18n/cs/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Import z Naver Maps', 'undo.addPlace': 'Místo přidáno', 'undo.done': 'Vráceno zpět: {action}', + 'undo.importBooking': 'Import potvrzení rezervace', }; export default undo; diff --git a/shared/src/i18n/de/reservations.ts b/shared/src/i18n/de/reservations.ts index b236343b..4930fc09 100644 --- a/shared/src/i18n/de/reservations.ts +++ b/shared/src/i18n/de/reservations.ts @@ -120,5 +120,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'Enddatum/-zeit muss nach dem Startdatum/-zeit liegen', 'reservations.addBooking': 'Buchung hinzufügen', + 'reservations.import.title': 'Buchungsbestätigungen importieren', + 'reservations.import.cta': 'Aus Datei importieren', + 'reservations.import.dropHere': 'Buchungsbestätigungsdateien hier ablegen oder klicken zum Auswählen', + 'reservations.import.dropActive': 'Dateien zum Importieren ablegen', + 'reservations.import.acceptedFormats': 'Akzeptiert: EML, PDF, PKPass, HTML, TXT (max. 10 MB pro Datei, bis zu 5 Dateien)', + 'reservations.import.parsing': 'Dateien werden verarbeitet…', + 'reservations.import.previewHeading': '{count} Reservierung(en) gefunden', + 'reservations.import.previewEmpty': 'Aus den hochgeladenen Dateien konnten keine Reservierungen extrahiert werden.', + 'reservations.import.removeItem': 'Entfernen', + 'reservations.import.confirm': '{count} Reservierung(en) importieren', + 'reservations.import.back': 'Zurück', + 'reservations.import.success': '{count} Reservierung(en) importiert', + 'reservations.import.partialFailure': '{created} importiert, {failed} fehlgeschlagen', + 'reservations.import.error': 'Verarbeitung fehlgeschlagen. Stellen Sie sicher, dass die Datei eine gültige Buchungsbestätigung ist.', + '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.', }; export default reservations; diff --git a/shared/src/i18n/de/undo.ts b/shared/src/i18n/de/undo.ts index bc165dc3..c877d9f1 100644 --- a/shared/src/i18n/de/undo.ts +++ b/shared/src/i18n/de/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Naver Maps-Import', 'undo.addPlace': 'Ort hinzugefügt', 'undo.done': 'Rückgängig gemacht: {action}', + 'undo.importBooking': 'Buchungsbestätigung-Import', }; export default undo; diff --git a/shared/src/i18n/en/reservations.ts b/shared/src/i18n/en/reservations.ts index f4c40f80..702ddcbe 100644 --- a/shared/src/i18n/en/reservations.ts +++ b/shared/src/i18n/en/reservations.ts @@ -119,5 +119,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'End date/time must be after start date/time', 'reservations.addBooking': 'Add booking', + 'reservations.import.title': 'Import booking confirmations', + 'reservations.import.cta': 'Import from file', + 'reservations.import.dropHere': 'Drop booking confirmation files here, or click to select', + 'reservations.import.dropActive': 'Drop files to import', + 'reservations.import.acceptedFormats': 'Accepted: EML, PDF, PKPass, HTML, TXT (max 10 MB each, up to 5 files)', + 'reservations.import.parsing': 'Parsing files…', + 'reservations.import.previewHeading': '{count} reservation(s) found', + 'reservations.import.previewEmpty': 'No reservations could be extracted from the uploaded files.', + 'reservations.import.removeItem': 'Remove', + 'reservations.import.confirm': 'Import {count} reservation(s)', + 'reservations.import.back': 'Back', + 'reservations.import.success': '{count} reservation(s) imported', + 'reservations.import.partialFailure': '{created} imported, {failed} failed', + 'reservations.import.error': 'Parsing failed. Make sure the file is a valid booking confirmation.', + '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.', }; export default reservations; diff --git a/shared/src/i18n/en/undo.ts b/shared/src/i18n/en/undo.ts index 54d92bc6..2a96e152 100644 --- a/shared/src/i18n/en/undo.ts +++ b/shared/src/i18n/en/undo.ts @@ -15,6 +15,7 @@ const undo: TranslationStrings = { 'undo.importKeyholeMarkup': 'KMZ/KML import', 'undo.importGoogleList': 'Google Maps import', 'undo.importNaverList': 'Naver Maps import', + 'undo.importBooking': 'Booking confirmation import', 'undo.addPlace': 'Place added', 'undo.done': 'Undone: {action}', }; diff --git a/shared/src/i18n/es/reservations.ts b/shared/src/i18n/es/reservations.ts index 6ac8a96e..33818bd8 100644 --- a/shared/src/i18n/es/reservations.ts +++ b/shared/src/i18n/es/reservations.ts @@ -119,5 +119,22 @@ const reservations: TranslationStrings = { 'reservations.meta.fromDay': 'Desde', 'reservations.meta.toDay': 'Hasta', 'reservations.meta.selectDay': 'Seleccionar día', + 'reservations.import.title': 'Importar confirmaciones de reserva', + 'reservations.import.cta': 'Importar desde archivo', + 'reservations.import.dropHere': 'Suelta los archivos de confirmación de reserva aquí o haz clic para seleccionar', + 'reservations.import.dropActive': 'Suelta los archivos para importar', + 'reservations.import.acceptedFormats': 'Aceptados: EML, PDF, PKPass, HTML, TXT (máx. 10 MB por archivo, hasta 5 archivos)', + 'reservations.import.parsing': 'Analizando archivos…', + 'reservations.import.previewHeading': '{count} reserva(s) encontrada(s)', + 'reservations.import.previewEmpty': 'No se pudieron extraer reservas de los archivos subidos.', + 'reservations.import.removeItem': 'Eliminar', + 'reservations.import.confirm': 'Importar {count} reserva(s)', + 'reservations.import.back': 'Atrás', + 'reservations.import.success': '{count} reserva(s) importada(s)', + 'reservations.import.partialFailure': '{created} importada(s), {failed} fallida(s)', + 'reservations.import.error': 'Error al analizar. Asegúrate de que el archivo sea una confirmación de reserva válida.', + '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.', }; export default reservations; diff --git a/shared/src/i18n/es/undo.ts b/shared/src/i18n/es/undo.ts index 0046d3b0..53277ae1 100644 --- a/shared/src/i18n/es/undo.ts +++ b/shared/src/i18n/es/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Importación de Naver Maps', 'undo.addPlace': 'Lugar agregado', 'undo.done': 'Deshecho: {action}', + 'undo.importBooking': 'Importar confirmación de reserva', }; export default undo; diff --git a/shared/src/i18n/fr/reservations.ts b/shared/src/i18n/fr/reservations.ts index d4ec6d01..7e5fd025 100644 --- a/shared/src/i18n/fr/reservations.ts +++ b/shared/src/i18n/fr/reservations.ts @@ -120,5 +120,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'La date/heure de fin doit être postérieure à la date/heure de début', 'reservations.addBooking': 'Ajouter une réservation', + 'reservations.import.title': 'Importer des confirmations de réservation', + 'reservations.import.cta': 'Importer depuis un fichier', + 'reservations.import.dropHere': 'Déposez les fichiers de confirmation de réservation ici ou cliquez pour sélectionner', + 'reservations.import.dropActive': 'Déposez les fichiers pour importer', + 'reservations.import.acceptedFormats': "Acceptés : EML, PDF, PKPass, HTML, TXT (max. 10 Mo chacun, jusqu'à 5 fichiers)", + 'reservations.import.parsing': 'Analyse des fichiers…', + 'reservations.import.previewHeading': '{count} réservation(s) trouvée(s)', + 'reservations.import.previewEmpty': "Aucune réservation n'a pu être extraite des fichiers envoyés.", + 'reservations.import.removeItem': 'Supprimer', + 'reservations.import.confirm': 'Importer {count} réservation(s)', + 'reservations.import.back': 'Retour', + 'reservations.import.success': '{count} réservation(s) importée(s)', + 'reservations.import.partialFailure': '{created} importée(s), {failed} échouée(s)', + 'reservations.import.error': 'Analyse échouée. Assurez-vous que le fichier est une confirmation de réservation valide.', + '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.', }; export default reservations; diff --git a/shared/src/i18n/fr/undo.ts b/shared/src/i18n/fr/undo.ts index 6e78e7f5..b241c3c8 100644 --- a/shared/src/i18n/fr/undo.ts +++ b/shared/src/i18n/fr/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Import Naver Maps', 'undo.addPlace': 'Lieu ajouté', 'undo.done': 'Annulé : {action}', + 'undo.importBooking': 'Import de confirmation de réservation', }; export default undo; diff --git a/shared/src/i18n/gr/reservations.ts b/shared/src/i18n/gr/reservations.ts index 79d30fca..6dcdf2e9 100644 --- a/shared/src/i18n/gr/reservations.ts +++ b/shared/src/i18n/gr/reservations.ts @@ -121,5 +121,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'Η ημερομηνία/ώρα λήξης πρέπει να είναι μετά την ημερομηνία/ώρα έναρξης', 'reservations.addBooking': 'Προσθήκη κράτησης', + 'reservations.import.title': 'Εισαγωγή επιβεβαιώσεων κράτησης', + 'reservations.import.cta': 'Εισαγωγή από αρχείο', + 'reservations.import.dropHere': 'Αποθέστε αρχεία επιβεβαίωσης κράτησης εδώ ή κάντε κλικ για επιλογή', + 'reservations.import.dropActive': 'Αποθέστε αρχεία για εισαγωγή', + 'reservations.import.acceptedFormats': 'Αποδεκτά: EML, PDF, PKPass, HTML, TXT (μέγιστο 10 MB το καθένα, έως 5 αρχεία)', + 'reservations.import.parsing': 'Επεξεργασία αρχείων…', + 'reservations.import.previewHeading': 'Βρέθηκαν {count} κράτηση/κρατήσεις', + 'reservations.import.previewEmpty': 'Δεν ήταν δυνατή η εξαγωγή κρατήσεων από τα μεταφορτωμένα αρχεία.', + 'reservations.import.removeItem': 'Αφαίρεση', + 'reservations.import.confirm': 'Εισαγωγή {count} κράτησης/κρατήσεων', + 'reservations.import.back': 'Πίσω', + 'reservations.import.success': '{count} κράτηση/κρατήσεις εισήχθησαν', + 'reservations.import.partialFailure': '{created} εισήχθησαν, {failed} απέτυχαν', + 'reservations.import.error': 'Η επεξεργασία απέτυχε. Βεβαιωθείτε ότι το αρχείο είναι έγκυρη επιβεβαίωση κράτησης.', + 'reservations.import.unavailable': 'Η εισαγωγή κρατήσεων δεν είναι διαθέσιμη σε αυτόν τον διακομιστή.', + 'reservations.import.unsupportedFormat': 'Μη υποστηριζόμενη μορφή αρχείου. Χρησιμοποιήστε EML, PDF, PKPass, HTML ή TXT.', + 'reservations.import.fileTooLarge': 'Το αρχείο «{name}» υπερβαίνει το όριο των 10 MB.', }; export default reservations; diff --git a/shared/src/i18n/gr/undo.ts b/shared/src/i18n/gr/undo.ts index 8bcf71cc..ba019d3f 100644 --- a/shared/src/i18n/gr/undo.ts +++ b/shared/src/i18n/gr/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Εισαγωγή Naver Maps', 'undo.addPlace': 'Η τοποθεσία προστέθηκε', 'undo.done': 'Αναιρέθηκε: {action}', + 'undo.importBooking': 'Εισαγωγή επιβεβαίωσης κράτησης', }; export default undo; diff --git a/shared/src/i18n/hu/reservations.ts b/shared/src/i18n/hu/reservations.ts index b531d0eb..54edc1c7 100644 --- a/shared/src/i18n/hu/reservations.ts +++ b/shared/src/i18n/hu/reservations.ts @@ -120,5 +120,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'A befejezés dátuma/időpontja a kezdés utáni kell legyen', 'reservations.addBooking': 'Foglalás hozzáadása', + 'reservations.import.title': 'Foglalási visszaigazolások importálása', + 'reservations.import.cta': 'Importálás fájlból', + 'reservations.import.dropHere': 'Dobja ide a foglalási visszaigazolás fájlokat, vagy kattintson a kiválasztáshoz', + 'reservations.import.dropActive': 'Dobja ide a fájlokat az importáláshoz', + 'reservations.import.acceptedFormats': 'Elfogadott: EML, PDF, PKPass, HTML, TXT (max. 10 MB darabonként, legfeljebb 5 fájl)', + 'reservations.import.parsing': 'Fájlok feldolgozása…', + 'reservations.import.previewHeading': '{count} foglalás találva', + 'reservations.import.previewEmpty': 'A feltöltött fájlokból nem sikerült foglalásokat kinyerni.', + 'reservations.import.removeItem': 'Eltávolítás', + 'reservations.import.confirm': '{count} foglalás importálása', + 'reservations.import.back': 'Vissza', + 'reservations.import.success': '{count} foglalás importálva', + 'reservations.import.partialFailure': '{created} importálva, {failed} sikertelen', + 'reservations.import.error': 'A feldolgozás sikertelen. Győződjön meg arról, hogy a fájl érvényes foglalási visszaigazolás.', + '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.', }; export default reservations; diff --git a/shared/src/i18n/hu/undo.ts b/shared/src/i18n/hu/undo.ts index cd13dd7e..91fecdba 100644 --- a/shared/src/i18n/hu/undo.ts +++ b/shared/src/i18n/hu/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Naver Maps importálás', 'undo.addPlace': 'Hely hozzáadva', 'undo.done': 'Visszavonva: {action}', + 'undo.importBooking': 'Foglalási visszaigazolás importálása', }; export default undo; diff --git a/shared/src/i18n/id/reservations.ts b/shared/src/i18n/id/reservations.ts index 4ab00854..5779b617 100644 --- a/shared/src/i18n/id/reservations.ts +++ b/shared/src/i18n/id/reservations.ts @@ -119,5 +119,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'Tanggal/waktu selesai harus setelah tanggal/waktu mulai', 'reservations.addBooking': 'Tambah pemesanan', + 'reservations.import.title': 'Impor konfirmasi pemesanan', + 'reservations.import.cta': 'Impor dari file', + 'reservations.import.dropHere': 'Seret file konfirmasi pemesanan ke sini atau klik untuk memilih', + 'reservations.import.dropActive': 'Lepaskan file untuk mengimpor', + 'reservations.import.acceptedFormats': 'Diterima: EML, PDF, PKPass, HTML, TXT (maks. 10 MB per file, hingga 5 file)', + 'reservations.import.parsing': 'Memproses file…', + 'reservations.import.previewHeading': '{count} pemesanan ditemukan', + 'reservations.import.previewEmpty': 'Tidak ada pemesanan yang dapat diekstrak dari file yang diunggah.', + 'reservations.import.removeItem': 'Hapus', + 'reservations.import.confirm': 'Impor {count} pemesanan', + 'reservations.import.back': 'Kembali', + 'reservations.import.success': '{count} pemesanan berhasil diimpor', + 'reservations.import.partialFailure': '{created} berhasil diimpor, {failed} gagal', + 'reservations.import.error': 'Pemrosesan gagal. Pastikan file adalah konfirmasi pemesanan yang valid.', + '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.', }; export default reservations; diff --git a/shared/src/i18n/id/undo.ts b/shared/src/i18n/id/undo.ts index 98fdf305..b6de356c 100644 --- a/shared/src/i18n/id/undo.ts +++ b/shared/src/i18n/id/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Impor Naver Maps', 'undo.addPlace': 'Tempat ditambahkan', 'undo.done': 'Dibatalkan: {action}', + 'undo.importBooking': 'Impor konfirmasi pemesanan', }; export default undo; diff --git a/shared/src/i18n/it/reservations.ts b/shared/src/i18n/it/reservations.ts index 0d87baae..a3918195 100644 --- a/shared/src/i18n/it/reservations.ts +++ b/shared/src/i18n/it/reservations.ts @@ -121,5 +121,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'La data/ora di fine deve essere successiva alla data/ora di inizio', 'reservations.addBooking': 'Aggiungi prenotazione', + 'reservations.import.title': 'Importa conferme di prenotazione', + 'reservations.import.cta': 'Importa da file', + 'reservations.import.dropHere': 'Trascina i file di conferma prenotazione qui o clicca per selezionare', + 'reservations.import.dropActive': 'Rilascia i file per importare', + 'reservations.import.acceptedFormats': 'Accettati: EML, PDF, PKPass, HTML, TXT (max 10 MB ciascuno, fino a 5 file)', + 'reservations.import.parsing': 'Analisi dei file in corso…', + 'reservations.import.previewHeading': '{count} prenotazione/i trovata/e', + 'reservations.import.previewEmpty': 'Nessuna prenotazione è stata estratta dai file caricati.', + 'reservations.import.removeItem': 'Rimuovi', + 'reservations.import.confirm': 'Importa {count} prenotazione/i', + 'reservations.import.back': 'Indietro', + 'reservations.import.success': '{count} prenotazione/i importata/e', + 'reservations.import.partialFailure': '{created} importata/e, {failed} fallita/e', + 'reservations.import.error': "Analisi fallita. Assicurati che il file sia una conferma di prenotazione valida.", + '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.', }; export default reservations; diff --git a/shared/src/i18n/it/undo.ts b/shared/src/i18n/it/undo.ts index efa2546f..a037f0ee 100644 --- a/shared/src/i18n/it/undo.ts +++ b/shared/src/i18n/it/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Importazione Naver Maps', 'undo.addPlace': 'Luogo aggiunto', 'undo.done': 'Annullato: {action}', + 'undo.importBooking': 'Importazione conferma prenotazione', }; export default undo; diff --git a/shared/src/i18n/ja/reservations.ts b/shared/src/i18n/ja/reservations.ts index d7f3869f..a11859d6 100644 --- a/shared/src/i18n/ja/reservations.ts +++ b/shared/src/i18n/ja/reservations.ts @@ -117,5 +117,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': '終了日時は開始日時より後である必要があります', 'reservations.addBooking': '予約を追加', + 'reservations.import.title': '予約確認書のインポート', + 'reservations.import.cta': 'ファイルからインポート', + 'reservations.import.dropHere': '予約確認ファイルをここにドロップするか、クリックして選択', + 'reservations.import.dropActive': 'ファイルをドロップしてインポート', + 'reservations.import.acceptedFormats': '対応形式:EML、PDF、PKPass、HTML、TXT(各最大 10 MB、最大 5 ファイル)', + 'reservations.import.parsing': 'ファイルを解析中…', + 'reservations.import.previewHeading': '{count} 件の予約が見つかりました', + 'reservations.import.previewEmpty': 'アップロードされたファイルから予約を抽出できませんでした。', + 'reservations.import.removeItem': '削除', + 'reservations.import.confirm': '{count} 件の予約をインポート', + 'reservations.import.back': '戻る', + 'reservations.import.success': '{count} 件の予約をインポートしました', + 'reservations.import.partialFailure': '{created} 件インポート済み、{failed} 件失敗', + 'reservations.import.error': '解析に失敗しました。ファイルが有効な予約確認書であることを確認してください。', + 'reservations.import.unavailable': 'このサーバーでは予約インポート機能が利用できません。', + 'reservations.import.unsupportedFormat': '対応していないファイル形式です。EML、PDF、PKPass、HTML、または TXT を使用してください。', + 'reservations.import.fileTooLarge': 'ファイル「{name}」は 10 MB の制限を超えています。', }; export default reservations; diff --git a/shared/src/i18n/ja/undo.ts b/shared/src/i18n/ja/undo.ts index cb8aafab..1b5c942a 100644 --- a/shared/src/i18n/ja/undo.ts +++ b/shared/src/i18n/ja/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Naverマップをインポート', 'undo.addPlace': '場所を追加', 'undo.done': '元に戻しました: {action}', + 'undo.importBooking': '予約確認書インポート', }; export default undo; diff --git a/shared/src/i18n/ko/reservations.ts b/shared/src/i18n/ko/reservations.ts index bf99aff3..38918c4b 100644 --- a/shared/src/i18n/ko/reservations.ts +++ b/shared/src/i18n/ko/reservations.ts @@ -117,5 +117,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': '종료 날짜/시간은 시작 날짜/시간 이후여야 합니다', 'reservations.addBooking': '예약 추가', + 'reservations.import.title': '예약 확인서 가져오기', + 'reservations.import.cta': '파일에서 가져오기', + 'reservations.import.dropHere': '예약 확인 파일을 여기에 끌어다 놓거나 클릭하여 선택', + 'reservations.import.dropActive': '가져올 파일을 여기에 놓으세요', + 'reservations.import.acceptedFormats': '허용 형식: EML, PDF, PKPass, HTML, TXT (파일당 최대 10 MB, 최대 5개)', + 'reservations.import.parsing': '파일 분석 중…', + 'reservations.import.previewHeading': '{count}개 예약 발견', + 'reservations.import.previewEmpty': '업로드된 파일에서 예약을 추출할 수 없었습니다.', + 'reservations.import.removeItem': '제거', + 'reservations.import.confirm': '{count}개 예약 가져오기', + 'reservations.import.back': '뒤로', + 'reservations.import.success': '{count}개 예약을 가져왔습니다', + 'reservations.import.partialFailure': '{created}개 가져옴, {failed}개 실패', + 'reservations.import.error': '분석 실패. 파일이 유효한 예약 확인서인지 확인하세요.', + 'reservations.import.unavailable': '이 서버에서는 예약 가져오기를 사용할 수 없습니다.', + 'reservations.import.unsupportedFormat': '지원하지 않는 파일 형식입니다. EML, PDF, PKPass, HTML 또는 TXT를 사용하세요.', + 'reservations.import.fileTooLarge': '파일 "{name}"이(가) 10 MB 제한을 초과합니다.', }; export default reservations; diff --git a/shared/src/i18n/ko/undo.ts b/shared/src/i18n/ko/undo.ts index 73e7cb02..dfe5cacb 100644 --- a/shared/src/i18n/ko/undo.ts +++ b/shared/src/i18n/ko/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': '네이버 지도 가져오기', 'undo.addPlace': '장소가 추가되었습니다', 'undo.done': '실행 취소됨: {action}', + 'undo.importBooking': '예약 확인서 가져오기', }; export default undo; diff --git a/shared/src/i18n/nl/reservations.ts b/shared/src/i18n/nl/reservations.ts index 4d4fcf49..a61a15b8 100644 --- a/shared/src/i18n/nl/reservations.ts +++ b/shared/src/i18n/nl/reservations.ts @@ -120,5 +120,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'Einddatum/-tijd moet na de startdatum/-tijd liggen', 'reservations.addBooking': 'Boeking toevoegen', + 'reservations.import.title': 'Boekingsbevestigingen importeren', + 'reservations.import.cta': 'Importeren vanuit bestand', + 'reservations.import.dropHere': 'Zet hier bevestigingsbestanden neer of klik om te selecteren', + 'reservations.import.dropActive': 'Laat bestanden los om te importeren', + 'reservations.import.acceptedFormats': 'Geaccepteerd: EML, PDF, PKPass, HTML, TXT (max. 10 MB per stuk, tot 5 bestanden)', + 'reservations.import.parsing': 'Bestanden verwerken…', + 'reservations.import.previewHeading': '{count} reservering(en) gevonden', + 'reservations.import.previewEmpty': 'Er konden geen reserveringen worden geëxtraheerd uit de geüploade bestanden.', + 'reservations.import.removeItem': 'Verwijderen', + 'reservations.import.confirm': '{count} reservering(en) importeren', + 'reservations.import.back': 'Terug', + 'reservations.import.success': '{count} reservering(en) geïmporteerd', + 'reservations.import.partialFailure': '{created} geïmporteerd, {failed} mislukt', + 'reservations.import.error': 'Verwerking mislukt. Zorg ervoor dat het bestand een geldige boekingsbevestiging is.', + '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.', }; export default reservations; diff --git a/shared/src/i18n/nl/undo.ts b/shared/src/i18n/nl/undo.ts index 65c6da94..4215dc3e 100644 --- a/shared/src/i18n/nl/undo.ts +++ b/shared/src/i18n/nl/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Naver Maps-import', 'undo.addPlace': 'Locatie toegevoegd', 'undo.done': 'Ongedaan gemaakt: {action}', + 'undo.importBooking': 'Boekingsbevestiging importeren', }; export default undo; diff --git a/shared/src/i18n/pl/reservations.ts b/shared/src/i18n/pl/reservations.ts index d2df7f9b..3f8328a3 100644 --- a/shared/src/i18n/pl/reservations.ts +++ b/shared/src/i18n/pl/reservations.ts @@ -120,5 +120,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'Data/godzina zakończenia musi być późniejsza niż data/godzina rozpoczęcia', 'reservations.addBooking': 'Dodaj rezerwację', + 'reservations.import.title': 'Importuj potwierdzenia rezerwacji', + 'reservations.import.cta': 'Importuj z pliku', + 'reservations.import.dropHere': 'Upuść pliki potwierdzeń rezerwacji tutaj lub kliknij, aby wybrać', + 'reservations.import.dropActive': 'Upuść pliki, aby zaimportować', + 'reservations.import.acceptedFormats': 'Akceptowane: EML, PDF, PKPass, HTML, TXT (maks. 10 MB każdy, do 5 plików)', + 'reservations.import.parsing': 'Przetwarzanie plików…', + 'reservations.import.previewHeading': 'Znaleziono {count} rezerwację/rezerwacje', + 'reservations.import.previewEmpty': 'Nie udało się wyodrębnić rezerwacji z przesłanych plików.', + 'reservations.import.removeItem': 'Usuń', + 'reservations.import.confirm': 'Importuj {count} rezerwację/rezerwacje', + 'reservations.import.back': 'Wstecz', + 'reservations.import.success': 'Zaimportowano {count} rezerwację/rezerwacje', + 'reservations.import.partialFailure': '{created} zaimportowano, {failed} nieudane', + 'reservations.import.error': 'Przetwarzanie nieudane. Upewnij się, że plik jest prawidłowym potwierdzeniem rezerwacji.', + '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.', }; export default reservations; diff --git a/shared/src/i18n/pl/undo.ts b/shared/src/i18n/pl/undo.ts index 5b968a03..241fd91d 100644 --- a/shared/src/i18n/pl/undo.ts +++ b/shared/src/i18n/pl/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Import Naver Maps', 'undo.addPlace': 'Miejsce dodane', 'undo.done': 'Cofnięto: {action}', + 'undo.importBooking': 'Import potwierdzenia rezerwacji', }; export default undo; diff --git a/shared/src/i18n/ru/reservations.ts b/shared/src/i18n/ru/reservations.ts index f4f09b5c..6ebc1620 100644 --- a/shared/src/i18n/ru/reservations.ts +++ b/shared/src/i18n/ru/reservations.ts @@ -120,5 +120,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'Дата/время окончания должны быть позже даты/времени начала', 'reservations.addBooking': 'Добавить бронирование', + 'reservations.import.title': 'Импорт подтверждений бронирования', + 'reservations.import.cta': 'Импортировать из файла', + 'reservations.import.dropHere': 'Перетащите файлы подтверждений бронирования сюда или нажмите для выбора', + 'reservations.import.dropActive': 'Отпустите файлы для импорта', + 'reservations.import.acceptedFormats': 'Принимаются: EML, PDF, PKPass, HTML, TXT (макс. 10 МБ каждый, до 5 файлов)', + 'reservations.import.parsing': 'Обработка файлов…', + 'reservations.import.previewHeading': 'Найдено {count} бронирование(й)', + 'reservations.import.previewEmpty': 'Из загруженных файлов не удалось извлечь бронирования.', + 'reservations.import.removeItem': 'Удалить', + 'reservations.import.confirm': 'Импортировать {count} бронирование(й)', + 'reservations.import.back': 'Назад', + 'reservations.import.success': '{count} бронирование(й) импортировано', + 'reservations.import.partialFailure': '{created} импортировано, {failed} не удалось', + 'reservations.import.error': 'Обработка не удалась. Убедитесь, что файл является действительным подтверждением бронирования.', + 'reservations.import.unavailable': 'Импорт бронирований недоступен на этом сервере.', + 'reservations.import.unsupportedFormat': 'Неподдерживаемый формат файла. Используйте EML, PDF, PKPass, HTML или TXT.', + 'reservations.import.fileTooLarge': 'Файл «{name}» превышает ограничение в 10 МБ.', }; export default reservations; diff --git a/shared/src/i18n/ru/undo.ts b/shared/src/i18n/ru/undo.ts index 48afd25b..377156a5 100644 --- a/shared/src/i18n/ru/undo.ts +++ b/shared/src/i18n/ru/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Импорт из Naver Maps', 'undo.addPlace': 'Место добавлено', 'undo.done': 'Отменено: {action}', + 'undo.importBooking': 'Импорт подтверждения бронирования', }; export default undo; diff --git a/shared/src/i18n/tr/reservations.ts b/shared/src/i18n/tr/reservations.ts index 98346b5b..5a3f647d 100644 --- a/shared/src/i18n/tr/reservations.ts +++ b/shared/src/i18n/tr/reservations.ts @@ -120,5 +120,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'Bitiş tarihi/saati başlangıçtan sonra olmalı', 'reservations.addBooking': 'Rezervasyon ekle', + 'reservations.import.title': 'Rezervasyon onaylarını içe aktar', + 'reservations.import.cta': 'Dosyadan içe aktar', + 'reservations.import.dropHere': 'Rezervasyon onay dosyalarını buraya sürükleyin veya seçmek için tıklayın', + 'reservations.import.dropActive': 'İçe aktarmak için dosyaları bırakın', + 'reservations.import.acceptedFormats': 'Kabul edilenler: EML, PDF, PKPass, HTML, TXT (her biri maks. 10 MB, en fazla 5 dosya)', + 'reservations.import.parsing': 'Dosyalar işleniyor…', + 'reservations.import.previewHeading': '{count} rezervasyon bulundu', + 'reservations.import.previewEmpty': 'Yüklenen dosyalardan hiçbir rezervasyon çıkarılamadı.', + 'reservations.import.removeItem': 'Kaldır', + 'reservations.import.confirm': '{count} rezervasyonu içe aktar', + 'reservations.import.back': 'Geri', + 'reservations.import.success': '{count} rezervasyon içe aktarıldı', + 'reservations.import.partialFailure': '{created} içe aktarıldı, {failed} başarısız', + 'reservations.import.error': 'İşlem başarısız. Dosyanın geçerli bir rezervasyon onayı olduğundan emin olun.', + '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.', }; export default reservations; diff --git a/shared/src/i18n/tr/undo.ts b/shared/src/i18n/tr/undo.ts index 94e458e6..75c28350 100644 --- a/shared/src/i18n/tr/undo.ts +++ b/shared/src/i18n/tr/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Naver Haritalar içe aktarma', 'undo.addPlace': 'Yer eklendi', 'undo.done': 'Geri alındı: {action}', + 'undo.importBooking': 'Rezervasyon onayı içe aktarma', }; export default undo; diff --git a/shared/src/i18n/uk/reservations.ts b/shared/src/i18n/uk/reservations.ts index ce1ce60f..984b5b47 100644 --- a/shared/src/i18n/uk/reservations.ts +++ b/shared/src/i18n/uk/reservations.ts @@ -120,5 +120,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': 'Дата/час закінчення повинен бути пізніше дати/часу початку', 'reservations.addBooking': 'Добавить бронирование', + 'reservations.import.title': 'Імпорт підтверджень бронювання', + 'reservations.import.cta': 'Імпортувати з файлу', + 'reservations.import.dropHere': 'Перетягніть файли підтверджень бронювання сюди або натисніть для вибору', + 'reservations.import.dropActive': 'Відпустіть файли для імпорту', + 'reservations.import.acceptedFormats': 'Підтримуються: EML, PDF, PKPass, HTML, TXT (макс. 10 МБ кожен, до 5 файлів)', + 'reservations.import.parsing': 'Обробка файлів…', + 'reservations.import.previewHeading': 'Знайдено {count} бронювання(нь)', + 'reservations.import.previewEmpty': 'З завантажених файлів не вдалося витягти бронювання.', + 'reservations.import.removeItem': 'Видалити', + 'reservations.import.confirm': 'Імпортувати {count} бронювання(нь)', + 'reservations.import.back': 'Назад', + 'reservations.import.success': '{count} бронювання(нь) імпортовано', + 'reservations.import.partialFailure': '{created} імпортовано, {failed} не вдалося', + 'reservations.import.error': 'Обробка не вдалася. Переконайтесь, що файл є дійсним підтвердженням бронювання.', + 'reservations.import.unavailable': 'Імпорт бронювань недоступний на цьому сервері.', + 'reservations.import.unsupportedFormat': 'Непідтримуваний формат файлу. Використовуйте EML, PDF, PKPass, HTML або TXT.', + 'reservations.import.fileTooLarge': 'Файл «{name}» перевищує обмеження в 10 МБ.', }; export default reservations; diff --git a/shared/src/i18n/uk/undo.ts b/shared/src/i18n/uk/undo.ts index d06cb58d..3b81c22b 100644 --- a/shared/src/i18n/uk/undo.ts +++ b/shared/src/i18n/uk/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Імпорт з Naver Maps', 'undo.addPlace': 'Місце додано', 'undo.done': 'Відмінено: {action}', + 'undo.importBooking': 'Імпорт підтвердження бронювання', }; export default undo; diff --git a/shared/src/i18n/zh-TW/reservations.ts b/shared/src/i18n/zh-TW/reservations.ts index 753354a8..2f8a5dee 100644 --- a/shared/src/i18n/zh-TW/reservations.ts +++ b/shared/src/i18n/zh-TW/reservations.ts @@ -116,5 +116,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': '結束日期/時間必須晚於開始日期/時間', 'reservations.addBooking': '新增預訂', + 'reservations.import.title': '匯入訂位確認', + 'reservations.import.cta': '從檔案匯入', + 'reservations.import.dropHere': '將訂位確認檔案拖放到此處,或點擊選擇', + 'reservations.import.dropActive': '放開檔案以匯入', + 'reservations.import.acceptedFormats': '支援格式:EML、PDF、PKPass、HTML、TXT(每個最大 10 MB,最多 5 個檔案)', + 'reservations.import.parsing': '正在解析檔案…', + 'reservations.import.previewHeading': '找到 {count} 筆預訂', + 'reservations.import.previewEmpty': '無法從上傳的檔案中提取任何預訂資訊。', + 'reservations.import.removeItem': '移除', + 'reservations.import.confirm': '匯入 {count} 筆預訂', + 'reservations.import.back': '返回', + 'reservations.import.success': '已匯入 {count} 筆預訂', + 'reservations.import.partialFailure': '已匯入 {created} 筆,{failed} 筆失敗', + 'reservations.import.error': '解析失敗。請確保檔案是有效的訂位確認。', + 'reservations.import.unavailable': '此伺服器上的預訂匯入功能不可用。', + 'reservations.import.unsupportedFormat': '不支援的檔案格式。請使用 EML、PDF、PKPass、HTML 或 TXT。', + 'reservations.import.fileTooLarge': '檔案「{name}」超過 10 MB 限制。', }; export default reservations; diff --git a/shared/src/i18n/zh-TW/undo.ts b/shared/src/i18n/zh-TW/undo.ts index 8be5ed12..14794570 100644 --- a/shared/src/i18n/zh-TW/undo.ts +++ b/shared/src/i18n/zh-TW/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Naver 地圖匯入', 'undo.addPlace': '地點已新增', 'undo.done': '已撤銷:{action}', + 'undo.importBooking': '匯入訂位確認', }; export default undo; diff --git a/shared/src/i18n/zh/reservations.ts b/shared/src/i18n/zh/reservations.ts index b3a5b4c6..facd1084 100644 --- a/shared/src/i18n/zh/reservations.ts +++ b/shared/src/i18n/zh/reservations.ts @@ -116,5 +116,22 @@ const reservations: TranslationStrings = { 'reservations.validation.endBeforeStart': '结束日期/时间必须晚于开始日期/时间', 'reservations.addBooking': '添加预订', + 'reservations.import.title': '导入预订确认', + 'reservations.import.cta': '从文件导入', + 'reservations.import.dropHere': '将预订确认文件拖放到此处,或点击选择', + 'reservations.import.dropActive': '松开文件以导入', + 'reservations.import.acceptedFormats': '支持格式:EML、PDF、PKPass、HTML、TXT(每个最大 10 MB,最多 5 个文件)', + 'reservations.import.parsing': '正在解析文件…', + 'reservations.import.previewHeading': '找到 {count} 个预订', + 'reservations.import.previewEmpty': '无法从上传的文件中提取任何预订信息。', + 'reservations.import.removeItem': '移除', + 'reservations.import.confirm': '导入 {count} 个预订', + 'reservations.import.back': '返回', + 'reservations.import.success': '已导入 {count} 个预订', + 'reservations.import.partialFailure': '已导入 {created} 个,{failed} 个失败', + 'reservations.import.error': '解析失败。请确保文件是有效的预订确认。', + 'reservations.import.unavailable': '此服务器上的预订导入功能不可用。', + 'reservations.import.unsupportedFormat': '不支持的文件格式。请使用 EML、PDF、PKPass、HTML 或 TXT。', + 'reservations.import.fileTooLarge': '文件"{name}"超过 10 MB 限制。', }; export default reservations; diff --git a/shared/src/i18n/zh/undo.ts b/shared/src/i18n/zh/undo.ts index 42de238d..14f15752 100644 --- a/shared/src/i18n/zh/undo.ts +++ b/shared/src/i18n/zh/undo.ts @@ -17,5 +17,6 @@ const undo: TranslationStrings = { 'undo.importNaverList': 'Naver 地图导入', 'undo.addPlace': '地点已添加', 'undo.done': '已撤销:{action}', + 'undo.importBooking': '导入预订确认', }; export default undo; diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts index d2033ff9..ee5ab5c1 100644 --- a/shared/src/reservation/reservation.schema.ts +++ b/shared/src/reservation/reservation.schema.ts @@ -141,3 +141,66 @@ export const accommodationUpdateRequestSchema = open; export type AccommodationUpdateRequest = z.infer< typeof accommodationUpdateRequestSchema >; + +// --------------------------------------------------------------------------- +// Booking import (KItinerary) +// --------------------------------------------------------------------------- + +const bookingImportEndpointSchema = z.object({ + role: z.enum(['from', 'to', 'stop']), + sequence: z.number(), + name: z.string(), + code: z.string().nullable(), + lat: z.number(), + lng: z.number(), + timezone: z.string().nullable(), + local_time: z.string().nullable(), + local_date: z.string().nullable(), +}); + +const bookingImportVenueSchema = z.object({ + name: z.string(), + lat: z.number().optional(), + lng: z.number().optional(), + address: z.string().optional(), + website: z.string().optional(), + phone: z.string().optional(), +}); + +const bookingImportAccommodationSchema = z.object({ + check_in: z.string().optional(), + check_out: z.string().optional(), + confirmation: z.string().optional(), +}); + +export const bookingImportPreviewItemSchema = z.object({ + type: z.string(), + title: z.string().min(1), + reservation_time: z.string().nullable().optional(), + reservation_end_time: z.string().nullable().optional(), + confirmation_number: z.string().nullable().optional(), + location: z.string().nullable().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + endpoints: z.array(bookingImportEndpointSchema).optional(), + needs_review: z.boolean().optional(), + _venue: bookingImportVenueSchema.optional(), + _accommodation: bookingImportAccommodationSchema.optional(), + source: z.object({ fileName: z.string(), index: z.number() }), +}); +export type BookingImportPreviewItem = z.infer; + +export const bookingImportPreviewResponseSchema = z.object({ + items: z.array(bookingImportPreviewItemSchema), + warnings: z.array(z.string()), +}); +export type BookingImportPreviewResponse = z.infer; + +export const bookingImportConfirmRequestSchema = z.object({ + items: z.array(bookingImportPreviewItemSchema).min(1), +}); +export type BookingImportConfirmRequest = z.infer; + +export const bookingImportConfirmResponseSchema = z.object({ + created: z.array(reservationSchema), +}); +export type BookingImportConfirmResponse = z.infer; diff --git a/wiki/Development-environment.md b/wiki/Development-environment.md index 4b5de7d8..66b6a684 100644 --- a/wiki/Development-environment.md +++ b/wiki/Development-environment.md @@ -84,7 +84,48 @@ npm i --- -## 6. Available Scripts +## 6. Optional: KItinerary (Booking Import) + +The booking-confirmation import feature uses [KDE KItinerary](https://apps.kde.org/itinerary/) to parse travel documents. The server works without it, but the import endpoint will be non-functional. + +### Linux — amd64 + +Download the static binary from the KDE CDN and verify the checksum: + +```bash +wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.0.tgz +echo "b7058d98990053c7b61847fef0c21e02d59b60e323e2b171ca210b682334e801 /tmp/ki.tgz" | sha256sum -c +sudo tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale +rm /tmp/ki.tgz +``` + +### Linux — arm64 + +```bash +sudo apt-get install -y libkitinerary-bin +sudo ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor +``` + +### Environment variables + +Add these to your local `.env` (or export them before starting the server): + +```bash +# Required: path to the extractor binary +KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor + +# Prevent Qt from probing for a display in headless/server environments +QT_QPA_PLATFORM=offscreen + +# KDE cache directory (avoids writing to $HOME) +XDG_CACHE_HOME=/tmp/kf6-cache +``` + +You can override `KITINERARY_EXTRACTOR_PATH` if you installed the binary to a different location. + +--- + +## 7. Available Scripts ### Server (`/server`) @@ -114,7 +155,7 @@ npm i --- -## 7. Commit & Push Your Changes +## 8. Commit & Push Your Changes ```bash git add . diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md index f67cdc4e..80282b81 100644 --- a/wiki/Environment-Variables.md +++ b/wiki/Environment-Variables.md @@ -137,6 +137,16 @@ For setup instructions, see [MCP-Overview](MCP-Overview). --- +## Booking Import (KDE Itinerary) + +| Variable | Description | Default | +|---|---|---| +| `KITINERARY_EXTRACTOR_PATH` | Full path to the `kitinerary-extractor` binary. When unset, TREK searches `/usr/lib/*/libexec/kf6/kitinerary-extractor` and then `PATH`. Set this if you install the binary to a non-standard location. | auto-detected | + +The official TREK Docker image bundles the binary automatically: on amd64 it downloads the static release from `https://cdn.kde.org/ci-builds/pim/kitinerary/`; on arm64 it installs `libkitinerary-bin` via apt (Debian trixie). When running TREK from source, install `libkitinerary-bin` (Debian trixie / Ubuntu 25.04+) or download the static binary directly and place it anywhere on `PATH`. The `GET /api/health/features` endpoint returns `{ "bookingImport": true }` when the binary is found, and the Import button in the Reservations panel is hidden when it is not. + +--- + ## Other | Variable | Description | Default | diff --git a/wiki/Reservations-and-Bookings.md b/wiki/Reservations-and-Bookings.md index 4ad23ee9..40815b2f 100644 --- a/wiki/Reservations-and-Bookings.md +++ b/wiki/Reservations-and-Bookings.md @@ -76,6 +76,47 @@ Click **Add** (or the + button) in the Reservations panel. Fill in the form: +## Import from booking confirmation + +TREK can parse booking confirmation emails, PDFs, and pass files and create reservations automatically using [KDE Itinerary](https://apps.kde.org/itinerary/). + +### Supported formats + +| Format | Extension | +|--------|-----------| +| Booking confirmation email | `.eml` | +| PDF ticket or confirmation | `.pdf` | +| Apple Wallet pass | `.pkpass` | +| HTML confirmation page | `.html`, `.htm` | +| Plain-text email | `.txt` | + +Up to 5 files, 10 MB each, per import. + +### How to import + +1. Open the **Reservations** tab. +2. Click the **Import** (download) button in the toolbar — the button is only shown when the extractor is available on your server. +3. Drag and drop your files onto the upload area, or click to browse. +4. TREK parses each file and shows a **preview list** of the detected reservations with type, title, dates, endpoints, and confirmation number. +5. Deselect any items you do not want to import by clicking the × on their card. +6. Click **Confirm** to create the selected reservations. + +All created reservations appear immediately in the panel and are broadcast to all connected trip members in real time. + +### What gets created automatically + +- **Hotels** — a reservation *and* a linked accommodation row in the day plan (check-in/check-out dates are read from the confirmation). +- **Hotels / Restaurants / Events** — the venue is auto-created as a place with coordinates when the extractor returns location data. +- **All types** — a budget entry is created if the Budget addon is enabled and a price is present. + +### When the button is not visible + +The Import button is hidden when the `kitinerary-extractor` binary is not available. The binary ships inside the official TREK Docker image. If you run TREK from source, install the `libkitinerary-bin` package (Debian trixie / Ubuntu 25.04+) or set `KITINERARY_EXTRACTOR_PATH` to the binary's full path. See [Environment-Variables](Environment-Variables). + +### Needs review flag + +Items that the extractor could only partially parse are flagged **Needs review** — an amber badge on the card. Review these reservations after import and fill in any missing fields manually. + ## Editing and deleting Each card has a pencil icon to open the edit form and a trash icon to delete. Deleting requires confirmation in a dialog before the record is removed. diff --git a/wiki/Transport-Flights-Trains-Cars.md b/wiki/Transport-Flights-Trains-Cars.md index b288e361..0e7b020f 100644 --- a/wiki/Transport-Flights-Trains-Cars.md +++ b/wiki/Transport-Flights-Trains-Cars.md @@ -81,4 +81,8 @@ See [Day-Plans-and-Notes](Day-Plans-and-Notes) for details. --- +> **Faster: import the confirmation** — If you have a booking confirmation email or PDF, you can skip the form entirely. See [Import from booking confirmation](Reservations-and-Bookings#import-from-booking-confirmation) in the Reservations guide. + +--- + **See also:** [Reservations-and-Bookings](Reservations-and-Bookings) · [Accommodations](Accommodations) · [Map-Features](Map-Features) · [Day-Plans-and-Notes](Day-Plans-and-Notes)