mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(reservations): native booking-confirmation import via KDE KItinerary (#1102)
* feat(reservations): native booking-confirmation import via KDE KItinerary
Adds a two-step preview → confirm flow for importing booking emails,
PDFs, PKPass and HTML confirmations. The server invokes the KDE
kitinerary-extractor binary, maps JSON-LD schema.org output to TREK
reservation shapes, and persists via the existing createReservation
pipeline (accommodations, budget, places, WebSocket broadcasts).
- NestJS BookingImportModule: preview + confirm endpoints under
/api/trips/:tripId/reservations/import/booking{,/confirm}
- KitineraryExtractorService: spawns the binary, filters stderr noise,
handles QDateTime (@value) timezone-aware datetimes
- kitinerary-mapper: FlightReservation, TrainReservation, BusReservation,
BoatReservation, LodgingReservation, FoodEstablishmentReservation,
RentalCarReservation, EventReservation → typed preview items
- BookingImportService: auto-creates place rows; geocodes venues without
coordinates via Nominatim (name+address → address → name fallback);
resolves day IDs for accommodation linking
- BookingImportModal: drag-and-drop multi-file upload, preview cards
with type icons, per-item exclude toggle, confirm step
- Shared Zod contracts: BookingImportPreviewItem, PreviewResponse,
ConfirmRequest, ConfirmResponse — consumed by controller, service,
API client and modal
- Dockerfile: node:24-trixie-slim runtime; amd64 downloads KDE static
binary + locales; arm64 installs libkitinerary-bin + symlinks to
fixed path; ENV KITINERARY_EXTRACTOR_PATH set for both arches
- /api/health/features exposes { bookingImport: boolean } so the UI
hides the Import button when the binary is absent
- i18n keys (English), wiki docs, API.md, README one-liner
* i18n: add booking import translations for all 19 non-English locales
Adds 17 reservations.import.* keys and undo.importBooking to ar, br, cs,
de, es, fr, gr, hu, id, it, ja, ko, nl, pl, ru, tr, uk, zh, zh-TW.
* chore: enforce i18n parity
* docs(wiki): add KItinerary local setup instructions to dev environment guide
This commit is contained in:
@@ -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
|
||||
|
||||
+28
-6
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<BookingImportPreviewResponse> => {
|
||||
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<BookingImportConfirmResponse> =>
|
||||
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 = {
|
||||
|
||||
@@ -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) => 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<string, React.FC<{ size: number; color?: string }>> = {
|
||||
flight: Plane,
|
||||
train: Train,
|
||||
hotel: Hotel,
|
||||
restaurant: UtensilsCrossed,
|
||||
car: Car,
|
||||
cruise: Anchor,
|
||||
event: Calendar,
|
||||
}
|
||||
|
||||
function typeColor(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
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<HTMLInputElement>(null)
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
type Phase = 'upload' | 'preview' | 'confirming'
|
||||
const [phase, setPhase] = useState<Phase>('upload')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [previewItems, setPreviewItems] = useState<BookingImportPreviewItem[]>([])
|
||||
const [warnings, setWarnings] = useState<string[]>([])
|
||||
const [excluded, setExcluded] = useState<Set<number>>(() => 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<HTMLInputElement>) => {
|
||||
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(
|
||||
<div
|
||||
className="bg-[rgba(0,0,0,0.4)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
|
||||
mouseDownTarget.current = null
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
{phase === 'preview' && (
|
||||
<button onClick={() => setPhase('upload')} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('reservations.import.title')}
|
||||
</div>
|
||||
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{/* Upload phase */}
|
||||
{phase === 'upload' && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||||
{t('reservations.import.acceptedFormats')}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTS.join(',')}
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
|
||||
{isDragOver ? (
|
||||
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
|
||||
) : files.length > 0 ? (
|
||||
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map(f => f.name).join(', ')}</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Preview phase */}
|
||||
{(phase === 'preview' || phase === 'confirming') && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 10 }}>
|
||||
{t('reservations.import.previewHeading', { count: previewItems.length })}
|
||||
</div>
|
||||
|
||||
{previewItems.length === 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||
{t('reservations.import.previewEmpty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<div
|
||||
key={`${item.source.fileName}-${idx}`}
|
||||
className={isExcluded ? 'bg-surface-tertiary' : 'bg-surface-secondary'}
|
||||
style={{
|
||||
borderRadius: 10, padding: '10px 12px', marginBottom: 8,
|
||||
border: `1px solid ${isExcluded ? 'var(--border-faint)' : 'var(--border-primary)'}`,
|
||||
opacity: isExcluded ? 0.5 : 1, transition: 'opacity 0.15s',
|
||||
display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
<Icon size={15} color={typeColor(item.type)} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.title}
|
||||
</div>
|
||||
{fromEp && toEp && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
|
||||
{fromEp.code ?? fromEp.name} → {toEp.code ?? toEp.name}
|
||||
</div>
|
||||
)}
|
||||
{item.reservation_time && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||
{formatDateTime(item.reservation_time)}
|
||||
{item.reservation_end_time && ` – ${formatDateTime(item.reservation_end_time)}`}
|
||||
</div>
|
||||
)}
|
||||
{item._accommodation?.check_in && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||
{formatDateTime(item._accommodation.check_in)} – {formatDateTime(item._accommodation.check_out)}
|
||||
</div>
|
||||
)}
|
||||
{item.confirmation_number && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'monospace' }}>
|
||||
{item.confirmation_number}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleExclude(idx)}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, flexShrink: 0, fontSize: 11, fontFamily: 'inherit', fontWeight: 500 }}
|
||||
title={t('reservations.import.removeItem')}
|
||||
>
|
||||
{isExcluded ? '+' : <X size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="bg-[rgba(245,158,11,0.08)] text-[#92400e]" style={{ border: '1px solid rgba(245,158,11,0.3)', borderRadius: 10, padding: '8px 10px', fontSize: 12, marginTop: 8, whiteSpace: 'pre-wrap' }}>
|
||||
{warnings.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
|
||||
{phase === 'upload' && (
|
||||
<button
|
||||
onClick={handleParse}
|
||||
disabled={files.length === 0 || loading}
|
||||
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{loading ? t('reservations.import.parsing') : t('common.import')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(phase === 'preview' || phase === 'confirming') && (
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={activeCount === 0 || phase === 'confirming'}
|
||||
className={activeCount > 0 && phase !== 'confirming' ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: activeCount > 0 && phase !== 'confirming' ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||
>
|
||||
{phase === 'confirming' ? t('common.loading') : t('reservations.import.confirm', { count: activeCount })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
flexShrink: 0,
|
||||
marginLeft: 'auto',
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
<span className="hidden sm:inline">{t(addManualKey)}</span>
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', flexShrink: 0 }}>
|
||||
{onImport && bookingImportAvailable && (
|
||||
<button onClick={onImport} className="bg-surface-card text-content" style={{
|
||||
appearance: 'none', border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '8px 13px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.75'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
title={t('reservations.import.title')}
|
||||
>
|
||||
<Download size={14} strokeWidth={2} />
|
||||
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
<span className="hidden sm:inline">{t(addManualKey)}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
onClose={() => setDeletePlaceId(null)}
|
||||
|
||||
@@ -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<boolean>(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
|
||||
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
||||
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
|
||||
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
|
||||
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
|
||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(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<number[]>(() => {
|
||||
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,
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
Generated
-1
@@ -5209,7 +5209,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BookingImportService['verifyTripAccess']>, 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<BookingImportConfirmResponse> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<ReturnType<typeof verifyTripAccess>>, 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<BookingImportPreviewResponse> {
|
||||
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<BookingImportConfirmResponse> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<KiReservation[]> {
|
||||
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/<triplet>/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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
endpoints?: ParsedEndpoint[];
|
||||
needs_review?: boolean;
|
||||
_venue?: ParsedVenue;
|
||||
_accommodation?: ParsedAccommodation;
|
||||
source: { fileName: string; index: number };
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,5 +17,6 @@ const undo: TranslationStrings = {
|
||||
'undo.importNaverList': 'استيراد خرائط Naver',
|
||||
'undo.addPlace': 'تمت إضافة المكان',
|
||||
'undo.done': 'تم التراجع: {action}',
|
||||
'undo.importBooking': 'استيراد تأكيد الحجز',
|
||||
};
|
||||
export default undo;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,5 +17,6 @@ const undo: TranslationStrings = {
|
||||
'undo.importNaverList': 'Εισαγωγή Naver Maps',
|
||||
'undo.addPlace': 'Η τοποθεσία προστέθηκε',
|
||||
'undo.done': 'Αναιρέθηκε: {action}',
|
||||
'undo.importBooking': 'Εισαγωγή επιβεβαίωσης κράτησης',
|
||||
};
|
||||
export default undo;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,5 +17,6 @@ const undo: TranslationStrings = {
|
||||
'undo.importNaverList': 'Naverマップをインポート',
|
||||
'undo.addPlace': '場所を追加',
|
||||
'undo.done': '元に戻しました: {action}',
|
||||
'undo.importBooking': '予約確認書インポート',
|
||||
};
|
||||
export default undo;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,5 +17,6 @@ const undo: TranslationStrings = {
|
||||
'undo.importNaverList': '네이버 지도 가져오기',
|
||||
'undo.addPlace': '장소가 추가되었습니다',
|
||||
'undo.done': '실행 취소됨: {action}',
|
||||
'undo.importBooking': '예약 확인서 가져오기',
|
||||
};
|
||||
export default undo;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,5 +17,6 @@ const undo: TranslationStrings = {
|
||||
'undo.importNaverList': 'Импорт из Naver Maps',
|
||||
'undo.addPlace': 'Место добавлено',
|
||||
'undo.done': 'Отменено: {action}',
|
||||
'undo.importBooking': 'Импорт подтверждения бронирования',
|
||||
};
|
||||
export default undo;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,5 +17,6 @@ const undo: TranslationStrings = {
|
||||
'undo.importNaverList': 'Імпорт з Naver Maps',
|
||||
'undo.addPlace': 'Місце додано',
|
||||
'undo.done': 'Відмінено: {action}',
|
||||
'undo.importBooking': 'Імпорт підтвердження бронювання',
|
||||
};
|
||||
export default undo;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,5 +17,6 @@ const undo: TranslationStrings = {
|
||||
'undo.importNaverList': 'Naver 地圖匯入',
|
||||
'undo.addPlace': '地點已新增',
|
||||
'undo.done': '已撤銷:{action}',
|
||||
'undo.importBooking': '匯入訂位確認',
|
||||
};
|
||||
export default undo;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,5 +17,6 @@ const undo: TranslationStrings = {
|
||||
'undo.importNaverList': 'Naver 地图导入',
|
||||
'undo.addPlace': '地点已添加',
|
||||
'undo.done': '已撤销:{action}',
|
||||
'undo.importBooking': '导入预订确认',
|
||||
};
|
||||
export default undo;
|
||||
|
||||
@@ -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<typeof bookingImportPreviewItemSchema>;
|
||||
|
||||
export const bookingImportPreviewResponseSchema = z.object({
|
||||
items: z.array(bookingImportPreviewItemSchema),
|
||||
warnings: z.array(z.string()),
|
||||
});
|
||||
export type BookingImportPreviewResponse = z.infer<typeof bookingImportPreviewResponseSchema>;
|
||||
|
||||
export const bookingImportConfirmRequestSchema = z.object({
|
||||
items: z.array(bookingImportPreviewItemSchema).min(1),
|
||||
});
|
||||
export type BookingImportConfirmRequest = z.infer<typeof bookingImportConfirmRequestSchema>;
|
||||
|
||||
export const bookingImportConfirmResponseSchema = z.object({
|
||||
created: z.array(reservationSchema),
|
||||
});
|
||||
export type BookingImportConfirmResponse = z.infer<typeof bookingImportConfirmResponseSchema>;
|
||||
|
||||
@@ -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 .
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -76,6 +76,47 @@ Click **Add** (or the + button) in the Reservations panel. Fill in the form:
|
||||
|
||||
<!-- TODO: screenshot: Create Reservation modal -->
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user