mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-26 08:41:47 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f21eba216 | |||
| 50eb88511c | |||
| ca3ffea3ea | |||
| e934fe43f1 | |||
| b175ef4626 | |||
| 9aaf313d59 | |||
| c5fb76da7b | |||
| 628830011d | |||
| c92c6bc07c | |||
| ccf0703f23 | |||
| 7291d9c52f | |||
| 156b8da37e | |||
| cee4b87cc9 | |||
| 223f5ce9bc | |||
| 5fa79bba52 | |||
| 23d5a5bd9c | |||
| a5d05cb92e | |||
| ac03b7ca13 | |||
| 22813f8d81 | |||
| 186625591a | |||
| 49fb2fded2 | |||
| 4cd4c9c8d8 | |||
| 6cc8908f87 | |||
| 68f48bc070 | |||
| 76d8abb44d | |||
| 91c350c946 | |||
| 1e4a9a95c2 | |||
| fe54f45d62 | |||
| b36c9931b3 | |||
| c1fe1d2d6a | |||
| ebbbf91d60 | |||
| 328d1c9468 | |||
| 48ebdff2d5 | |||
| 457a42b229 | |||
| 7df5956920 | |||
| 0d50d5d7c3 | |||
| 4a3aa478c6 | |||
| abee2fc088 | |||
| e40465ba1f | |||
| 8dab26fe7b | |||
| 7459067b2e | |||
| a2c552f04d | |||
| 27762458e6 | |||
| adbe15abc4 | |||
| 982b99f0f6 | |||
| 6a797a39ae | |||
| d2cd317070 | |||
| 6ab6d79494 | |||
| d35972db39 |
+3
-15
@@ -46,23 +46,11 @@ 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 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 wget ca-certificates python3 build-essential && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
|
||||
libkitinerary-bin && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
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.2.tgz && \
|
||||
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /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 && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -20,6 +20,7 @@ import SharedTripPage from './pages/SharedTripPage'
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
|
||||
import BottomNav from './components/Layout/BottomNav'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
@@ -208,6 +209,7 @@ export default function App() {
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
{!isAuthPage && <BackgroundTasksWidget />}
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type BookingImportPreviewItem,
|
||||
type BookingImportPreviewResponse,
|
||||
type BookingImportConfirmResponse,
|
||||
type BookingImportMode,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
@@ -441,6 +442,41 @@ export const adminApi = {
|
||||
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
|
||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
// Local LLM (Ollama) management for the AI-parsing addon.
|
||||
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
|
||||
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
|
||||
/** Pull a model, streaming Ollama's NDJSON progress to `onProgress`. */
|
||||
llmLocalPull: async (
|
||||
baseUrl: string,
|
||||
model: string,
|
||||
onProgress: (p: { status?: string; total?: number; completed?: number; error?: string }) => void,
|
||||
): Promise<void> => {
|
||||
const res = await fetch('/api/admin/llm/local/pull', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseUrl, model }),
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
let msg = `Pull failed (${res.status})`
|
||||
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const dec = new TextDecoder()
|
||||
let buf = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += dec.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
|
||||
}
|
||||
}
|
||||
},
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
@@ -624,17 +660,31 @@ 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> => {
|
||||
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): 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)
|
||||
fd.append('mode', mode)
|
||||
// No client-side timeout: kitinerary + LLM extraction routinely exceeds the
|
||||
// global 8s default (a cold local model alone can take ~45s).
|
||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).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),
|
||||
// Start a background parse: returns a job id at once; progress + result arrive
|
||||
// over the WebSocket (import:progress / import:done / import:error).
|
||||
importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => {
|
||||
const fd = new FormData()
|
||||
for (const f of files) fd.append('files', f)
|
||||
fd.append('mode', mode)
|
||||
return apiClient.post(`/trips/${tripId}/reservations/import/booking/async`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
|
||||
},
|
||||
// Poll a background job — recovery path when a WebSocket push was missed.
|
||||
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
|
||||
apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const healthApi = {
|
||||
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
||||
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data),
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
|
||||
@@ -298,7 +299,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
</span>
|
||||
</div>
|
||||
{integrationAddons.map(addon => (
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'llm_parsing' && addon.enabled && (
|
||||
<LlmParsingConfig addon={addon} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -309,6 +315,226 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
}
|
||||
|
||||
const MASKED = '••••••••'
|
||||
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
|
||||
|
||||
/** Curated models the local extractor is tuned for, pullable via Ollama. The router
|
||||
* uses the strong model for flights/multi-item docs and the small one (when installed)
|
||||
* for simple single-item bookings — so a host only needs these two. */
|
||||
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
|
||||
{ id: 'qwen2.5:7b', label: 'Qwen2.5 — 7B', note: 'Recommended · reliable for flights & multi-item bookings · Apache-2.0', recommended: true, vision: false },
|
||||
{ id: 'qwen2.5:3b', label: 'Qwen2.5 — 3B', note: 'Optional · used automatically for simple bookings (~3× faster) · Apache-2.0', recommended: false, vision: false },
|
||||
]
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config. When set, applies to the whole instance and
|
||||
* overrides per-user config (see server llmConfig.ts). The API key is masked on
|
||||
* read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider, it also lists installed Ollama models and can pull NuExtract models.
|
||||
*/
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const toast = useToast()
|
||||
const cfg = (addon.config ?? {}) as Record<string, unknown>
|
||||
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
|
||||
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
|
||||
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
|
||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Local-provider model management.
|
||||
const [installed, setInstalled] = useState<string[]>([])
|
||||
const [modelsErr, setModelsErr] = useState('')
|
||||
const [loadingModels, setLoadingModels] = useState(false)
|
||||
const [pulling, setPulling] = useState<string | null>(null)
|
||||
const [pullPct, setPullPct] = useState(0)
|
||||
const [pullStatus, setPullStatus] = useState('')
|
||||
|
||||
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
|
||||
const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
|
||||
|
||||
const loadModels = async () => {
|
||||
if (provider !== 'local') return
|
||||
setLoadingModels(true)
|
||||
setModelsErr('')
|
||||
try {
|
||||
const res = await adminApi.llmLocalModels(effectiveUrl)
|
||||
setInstalled(res.models.map(m => m.name))
|
||||
} catch (e: unknown) {
|
||||
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
|
||||
setInstalled([])
|
||||
} finally {
|
||||
setLoadingModels(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load installed models when the local provider is active.
|
||||
useEffect(() => {
|
||||
if (provider === 'local') loadModels()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [provider])
|
||||
|
||||
const pull = async (id: string) => {
|
||||
if (pulling) return
|
||||
setPulling(id)
|
||||
setPullPct(0)
|
||||
setPullStatus('starting…')
|
||||
try {
|
||||
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
|
||||
if (p.error) throw new Error(p.error)
|
||||
if (p.status) setPullStatus(p.status)
|
||||
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
|
||||
})
|
||||
toast.success('Model pulled')
|
||||
setModel(id)
|
||||
await loadModels()
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Pull failed')
|
||||
} finally {
|
||||
setPulling(null)
|
||||
setPullPct(0)
|
||||
setPullStatus('')
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
// Send the masked sentinel unchanged so the server keeps the stored key.
|
||||
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
|
||||
toast.success('Saved')
|
||||
} catch {
|
||||
toast.error('Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
|
||||
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
|
||||
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<p className="text-xs text-content-faint">
|
||||
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
|
||||
</p>
|
||||
|
||||
{/* Connection */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Connection</div>
|
||||
<div>
|
||||
<span className={labelCls}>Provider</span>
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</div>
|
||||
{provider !== 'anthropic' && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Base URL</span>
|
||||
<input className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</label>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>API key</span>
|
||||
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</label>
|
||||
{provider === 'anthropic' && (
|
||||
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Model */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Model</div>
|
||||
<label className="block">
|
||||
<input className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
|
||||
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<p className="text-xs text-content-faint">No models installed yet — pull one below.</p>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-edge-secondary pt-3">
|
||||
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
|
||||
<div className="space-y-1">
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-content">{m.label}</span>
|
||||
{m.recommended && (
|
||||
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-content-faint">{m.note}</div>
|
||||
{isPulling && (
|
||||
<div className="mt-1.5">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{installedHere ? (
|
||||
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addon: Addon) => void
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { reservationsApi } from '../../api/client'
|
||||
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
|
||||
|
||||
/**
|
||||
* Global, route-independent widget (bottom-right) that tracks background booking
|
||||
* imports. Mounted once at the app root so it survives navigation. It listens to the
|
||||
* user's WebSocket for import:progress / import:done / import:error and reflects each
|
||||
* job; a finished job offers a "review" action that takes the user to the trip, where
|
||||
* the per-item review flow opens. Polls running jobs as a backstop for missed pushes.
|
||||
*/
|
||||
export default function BackgroundTasksWidget() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const tasks = useBackgroundTasksStore((s) => s.tasks)
|
||||
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
|
||||
const setDone = useBackgroundTasksStore((s) => s.setDone)
|
||||
const setError = useBackgroundTasksStore((s) => s.setError)
|
||||
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
|
||||
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
|
||||
|
||||
// On (re)load, reconcile tasks restored from localStorage with the server: a parse
|
||||
// that was still running when the page reloaded must keep its widget, so re-fetch each
|
||||
// job's real status (and its parsed items) once. A job the server has since dropped
|
||||
// (404, expired) is removed so no stale card lingers.
|
||||
const didRehydrate = useRef(false)
|
||||
useEffect(() => {
|
||||
if (didRehydrate.current) return
|
||||
didRehydrate.current = true
|
||||
const restored = useBackgroundTasksStore.getState().tasks
|
||||
for (const task of restored) {
|
||||
reservationsApi
|
||||
.importJobStatus(task.tripId, task.id)
|
||||
.then((s) => {
|
||||
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
|
||||
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
|
||||
else setProgress(task.id, task.tripId, s.done, s.total)
|
||||
})
|
||||
.catch((err: { response?: { status?: number } }) => {
|
||||
if (err?.response?.status === 404) dismiss(task.id)
|
||||
})
|
||||
}
|
||||
// run once on mount against whatever was rehydrated from storage
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Server pushes import:* to the user on whatever page they're on.
|
||||
useEffect(() => {
|
||||
const handler = (e: Record<string, unknown>) => {
|
||||
const type = typeof e.type === 'string' ? e.type : ''
|
||||
if (!type.startsWith('import:')) return
|
||||
const id = String(e.jobId ?? '')
|
||||
const tripId = String(e.tripId ?? '')
|
||||
if (!id) return
|
||||
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
|
||||
else if (type === 'import:done') {
|
||||
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
|
||||
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
|
||||
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [setProgress, setDone, setError])
|
||||
|
||||
// Backstop: poll jobs whose state we still need — running ones (in case a WebSocket push
|
||||
// was missed) and a restored 'done' task whose items haven't been re-fetched yet (so a
|
||||
// failed one-shot rehydrate self-heals instead of getting stuck on "preview empty").
|
||||
useEffect(() => {
|
||||
const pending = tasks.filter((task) => task.status === 'running' || (task.status === 'done' && task.items === undefined))
|
||||
if (pending.length === 0) return
|
||||
const iv = setInterval(() => {
|
||||
for (const task of pending) {
|
||||
reservationsApi
|
||||
.importJobStatus(task.tripId, task.id)
|
||||
.then((s) => {
|
||||
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
|
||||
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
|
||||
else setProgress(task.id, task.tripId, s.done, s.total)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(iv)
|
||||
}, [tasks, setProgress, setDone, setError])
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
const review = (task: BackgroundImportTask) => {
|
||||
requestReview(task.id)
|
||||
navigate(`/trips/${task.tripId}`)
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
|
||||
>
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
|
||||
>
|
||||
<div style={{ flexShrink: 0, marginTop: 1 }}>
|
||||
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
|
||||
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
|
||||
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.label}
|
||||
</div>
|
||||
|
||||
{task.status === 'running' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
|
||||
{t('reservations.import.parsing')}
|
||||
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.status === 'done' && (
|
||||
task.items === undefined ? (
|
||||
// Restored from a reload; items are being re-fetched (see the poll backstop).
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
|
||||
) : task.items.length > 0 ? (
|
||||
<button
|
||||
onClick={() => review(task)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.import')}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.previewEmpty')}</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{task.status === 'error' && (
|
||||
<div style={{ fontSize: 11, color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.status !== 'running' && (
|
||||
<button
|
||||
onClick={() => dismiss(task.id)}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,81 +1,43 @@
|
||||
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 { Upload, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { reservationsApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { reservationsApi, healthApi } from '../../api/client'
|
||||
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
||||
|
||||
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) {
|
||||
/**
|
||||
* Upload booking files and kick off a BACKGROUND parse. The modal closes at once;
|
||||
* the parse runs server-side and is tracked by the global BackgroundTasksWidget
|
||||
* (progress over the WebSocket). When it finishes, the trip page opens the per-item
|
||||
* review flow — so the user can navigate and keep editing while it works.
|
||||
*/
|
||||
export default function BookingImportModal({ isOpen, onClose, tripId }: BookingImportModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const addTask = useBackgroundTasksStore((s) => s.addTask)
|
||||
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 [aiParsing, setAiParsing] = useState(false)
|
||||
|
||||
const reset = () => {
|
||||
setPhase('upload')
|
||||
setFiles([])
|
||||
setIsDragOver(false)
|
||||
setLoading(false)
|
||||
setError('')
|
||||
setPreviewItems([])
|
||||
setWarnings([])
|
||||
setExcluded(new Set())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,6 +46,11 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
|
||||
}, [isOpen])
|
||||
|
||||
const handleClose = () => { reset(); onClose() }
|
||||
|
||||
const validateFile = (f: File): string | null => {
|
||||
@@ -121,88 +88,41 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
|
||||
if (list.length) selectFiles(list)
|
||||
}
|
||||
|
||||
// Start the parse in the background and close — the widget takes it from here.
|
||||
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'))
|
||||
}
|
||||
|
||||
const mode = aiParsing ? 'fallback-on-empty' : 'no-ai'
|
||||
const { jobId } = await reservationsApi.importBookingAsync(tripId, files, mode)
|
||||
addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length })
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error ?? t('reservations.import.error'))
|
||||
setPhase('preview')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 => {
|
||||
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()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-surface-card"
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{/* 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>
|
||||
@@ -212,131 +132,45 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
|
||||
</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>
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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}
|
||||
@@ -352,28 +186,14 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { openFile } from '../../utils/fileDownload'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { BookingReviewDraft } from './parsedItemToDraft'
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
@@ -64,9 +65,12 @@ interface ReservationModalProps {
|
||||
accommodations?: Accommodation[]
|
||||
defaultAssignmentId?: number | null
|
||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||
// Pre-fill a brand-new booking from a parsed import item (review-before-save).
|
||||
// Distinct from `reservation`: the form is populated but stays in create mode.
|
||||
prefill?: BookingReviewDraft | null
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense }: ReservationModalProps) {
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense, prefill = null }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const toast = useToast()
|
||||
@@ -84,6 +88,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||
hotel_address: '',
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
@@ -97,6 +102,32 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Resolve an ISO date to a trip day id (exact match, else nearest).
|
||||
const dayIdForDate = (iso: unknown): string | number => {
|
||||
if (!iso) return ''
|
||||
const date = String(iso).slice(0, 10)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return ''
|
||||
const exact = days.find(d => d.date === date)
|
||||
if (exact) return exact.id
|
||||
let best: string | number = ''
|
||||
let bestDiff = Infinity
|
||||
for (const d of days) {
|
||||
if (!d.date) continue
|
||||
const diff = Math.abs(new Date(d.date).getTime() - new Date(date).getTime())
|
||||
if (diff < bestDiff) { bestDiff = diff; best = d.id }
|
||||
}
|
||||
return best
|
||||
}
|
||||
// Match an existing place by name (exact, then loose contains) for hotels.
|
||||
const matchPlaceId = (name: string | undefined): string | number => {
|
||||
const n = (name || '').trim().toLowerCase()
|
||||
if (!n) return ''
|
||||
const exact = places.find(p => p.name?.trim().toLowerCase() === n)
|
||||
if (exact) return exact.id
|
||||
const loose = places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
|
||||
return loose?.id ?? ''
|
||||
}
|
||||
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
||||
const rawEnd = reservation.reservation_end_time || ''
|
||||
@@ -109,6 +140,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
endDate = rawEnd
|
||||
endTime = ''
|
||||
}
|
||||
const editAcc = accommodations.find(a => a.id == reservation.accommodation_id)
|
||||
setForm({
|
||||
title: reservation.title || '',
|
||||
type: reservation.type || 'other',
|
||||
@@ -124,21 +156,52 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
meta_check_in_end_time: meta.check_in_end_time || '',
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
||||
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
||||
hotel_place_id: editAcc?.place_id || '',
|
||||
hotel_start_day: editAcc?.start_day_id || '',
|
||||
hotel_end_day: editAcc?.end_day_id || '',
|
||||
hotel_address: places.find(p => p.id == editAcc?.place_id)?.address || '',
|
||||
})
|
||||
} else if (prefill) {
|
||||
// Review-before-save: populate from a parsed import item, stay in create mode.
|
||||
const meta = (prefill.metadata && typeof prefill.metadata === 'object' ? prefill.metadata : {}) as Record<string, string>
|
||||
const rawEnd = typeof prefill.reservation_end_time === 'string' ? prefill.reservation_end_time : ''
|
||||
let endDate = ''
|
||||
let endTime = rawEnd
|
||||
if (rawEnd.includes('T')) { endDate = rawEnd.split('T')[0]; endTime = rawEnd.split('T')[1]?.slice(0, 5) || '' }
|
||||
else if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) { endDate = rawEnd; endTime = '' }
|
||||
setForm({
|
||||
title: prefill.title || '',
|
||||
type: prefill.type || 'other',
|
||||
status: prefill.status || 'pending',
|
||||
reservation_time: typeof prefill.reservation_time === 'string' ? prefill.reservation_time.slice(0, 16) : '',
|
||||
reservation_end_time: endTime,
|
||||
end_date: endDate,
|
||||
location: prefill.location || '',
|
||||
confirmation_number: prefill.confirmation_number || '',
|
||||
notes: prefill.notes || '',
|
||||
assignment_id: defaultAssignmentId ?? '',
|
||||
accommodation_id: '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
meta_check_in_end_time: meta.check_in_end_time || '',
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
hotel_place_id: matchPlaceId(prefill._venue?.name || prefill.title),
|
||||
hotel_start_day: dayIdForDate(prefill._accommodation?.check_in),
|
||||
hotel_end_day: dayIdForDate(prefill._accommodation?.check_out),
|
||||
hotel_address: prefill._venue?.address || '',
|
||||
})
|
||||
setPendingFiles([])
|
||||
} else {
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '',
|
||||
})
|
||||
setPendingFiles([])
|
||||
}
|
||||
}, [reservation, isOpen, selectedDayId, defaultAssignmentId])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId, days, places, accommodations])
|
||||
|
||||
// Re-hydrate hotel day range when the accommodations prop arrives after the modal opens
|
||||
// (race: tripAccommodations fetch may complete after isOpen fires, leaving hotel fields empty)
|
||||
@@ -194,17 +257,33 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
endpoints: [],
|
||||
needs_review: false,
|
||||
}
|
||||
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
||||
if (form.type === 'hotel' && (form.hotel_start_day || form.hotel_end_day)) {
|
||||
saveData.create_accommodation = {
|
||||
place_id: form.hotel_place_id || null,
|
||||
start_day_id: form.hotel_start_day,
|
||||
end_day_id: form.hotel_end_day,
|
||||
// No existing place picked but we have an address/name (e.g. a reviewed
|
||||
// import) → the save handler geocodes it and creates the place.
|
||||
venue: (!form.hotel_place_id && (form.hotel_address || form.title))
|
||||
? { name: form.title, address: form.hotel_address || null }
|
||||
: null,
|
||||
// Tolerate a single resolved end of the range (a one-night stay or a date
|
||||
// that only matched one trip day) so the accommodation is still created.
|
||||
start_day_id: form.hotel_start_day || form.hotel_end_day,
|
||||
end_day_id: form.hotel_end_day || form.hotel_start_day,
|
||||
check_in: form.meta_check_in_time || null,
|
||||
check_in_end: form.meta_check_in_end_time || null,
|
||||
check_out: form.meta_check_out_time || null,
|
||||
confirmation: form.confirmation_number || null,
|
||||
}
|
||||
}
|
||||
// Imported booking → auto-create the linked cost from the parsed price (what the
|
||||
// old direct import did). Only on create (not edit) and only when there's a price.
|
||||
if (!reservation && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
saveData.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
}
|
||||
const saved = await onSave(saveData)
|
||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
|
||||
for (const file of pendingFiles) {
|
||||
@@ -497,6 +576,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.locationAddress')}</label>
|
||||
<input type="text" value={form.hotel_address} onChange={e => set('hotel_address', e.target.value)}
|
||||
placeholder={t('reservations.locationPlaceholder')} className={inputClass} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.checkIn')}</label>
|
||||
|
||||
@@ -312,7 +312,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
if (!hasEndpoints && meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat + (meta.class ? ` · ${meta.class}` : '') })
|
||||
if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: `${meta.price}${meta.priceCurrency ? ' ' + meta.priceCurrency : ''}` })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||
if (cells.length === 0) return null
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from
|
||||
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { BookingReviewDraft } from './parsedItemToDraft'
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
||||
@@ -126,9 +127,12 @@ interface TransportModalProps {
|
||||
onFileUpload?: (fd: FormData) => Promise<unknown>
|
||||
onFileDelete?: (fileId: number) => Promise<void>
|
||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||
// Pre-fill a brand-new transport booking from a parsed import item (review-
|
||||
// before-save); like `reservation` for the form but stays in create mode.
|
||||
prefill?: BookingReviewDraft | null
|
||||
}
|
||||
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense }: TransportModalProps) {
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null }: TransportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
@@ -151,28 +155,53 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Resolve a trip day from a YYYY-MM-DD string: exact match, else the nearest day so an
|
||||
// imported booking still lands on one. An imported transport arrives without a day_id
|
||||
// (only its parsed dates), and without a selected day the save would drop the date and
|
||||
// store a bare "HH:MM" — see buildTime below.
|
||||
const dayIdForDate = (dateStr: string | null): number | '' => {
|
||||
if (!dateStr || days.length === 0) return ''
|
||||
const exact = days.find(d => d.date === dateStr)
|
||||
if (exact) return exact.id
|
||||
const target = new Date(dateStr).getTime()
|
||||
if (Number.isNaN(target)) return ''
|
||||
let best = days[0]
|
||||
let bestDiff = Infinity
|
||||
for (const d of days) {
|
||||
const diff = Math.abs(new Date(d.date).getTime() - target)
|
||||
if (diff < bestDiff) { bestDiff = diff; best = d }
|
||||
}
|
||||
return best.id
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string'
|
||||
? JSON.parse(reservation.metadata || '{}')
|
||||
: (reservation.metadata || {})
|
||||
const eps = reservation.endpoints || []
|
||||
// Edit uses the saved `reservation`; a review-import populates from `prefill`.
|
||||
// Either way the init reads the same fields — `reservation` still decides
|
||||
// edit-vs-create at submit time.
|
||||
const src = (reservation ?? prefill) as Reservation | null
|
||||
if (src) {
|
||||
const meta = typeof src.metadata === 'string'
|
||||
? JSON.parse(src.metadata || '{}')
|
||||
: (src.metadata || {})
|
||||
const eps = src.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
const type = (TRANSPORT_TYPES as readonly string[]).includes(reservation.type)
|
||||
? reservation.type as TransportType
|
||||
const type = (TRANSPORT_TYPES as readonly string[]).includes(src.type)
|
||||
? src.type as TransportType
|
||||
: 'flight'
|
||||
setForm({
|
||||
title: reservation.title || '',
|
||||
title: src.title || '',
|
||||
type,
|
||||
status: reservation.status === 'confirmed' ? 'confirmed' : 'pending',
|
||||
start_day_id: reservation.day_id ?? '',
|
||||
end_day_id: reservation.end_day_id ?? '',
|
||||
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
|
||||
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
status: src.status === 'confirmed' ? 'confirmed' : 'pending',
|
||||
// For an edit, keep the saved day; for an imported prefill (no day_id), resolve it
|
||||
// from the parsed pick-up/return date so the date isn't lost on save.
|
||||
start_day_id: src.day_id ?? dayIdForDate(splitReservationDateTime(src.reservation_time).date),
|
||||
end_day_id: src.end_day_id ?? dayIdForDate(splitReservationDateTime(src.reservation_end_time).date),
|
||||
departure_time: splitReservationDateTime(src.reservation_time).time ?? '',
|
||||
arrival_time: splitReservationDateTime(src.reservation_end_time).time ?? '',
|
||||
confirmation_number: src.confirmation_number || '',
|
||||
notes: src.notes || '',
|
||||
meta_airline: meta.airline || '',
|
||||
meta_flight_number: meta.flight_number || '',
|
||||
meta_train_number: meta.train_number || '',
|
||||
@@ -180,7 +209,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
meta_seat: meta.seat || '',
|
||||
})
|
||||
if (type === 'flight') {
|
||||
const orderedEps = orderedEndpoints(reservation)
|
||||
const orderedEps = orderedEndpoints(src)
|
||||
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
|
||||
let wps: WaypointForm[]
|
||||
if (orderedEps.length >= 2) {
|
||||
@@ -191,9 +220,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const isLast = i === orderedEps.length - 1
|
||||
return {
|
||||
airport: airportFromEndpoint(ep),
|
||||
arrDayId: legInto?.arr_day_id ?? (isLast ? (reservation.end_day_id ?? '') : ''),
|
||||
arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''),
|
||||
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
|
||||
depDayId: legOut?.dep_day_id ?? (isFirst ? (reservation.day_id ?? '') : ''),
|
||||
depDayId: legOut?.dep_day_id ?? (isFirst ? (src.day_id ?? '') : ''),
|
||||
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
||||
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
|
||||
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
|
||||
@@ -202,15 +231,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
})
|
||||
} else {
|
||||
// Legacy flight with no (or partial) endpoints — seed two waypoints.
|
||||
const dep = emptyWaypoint(reservation.day_id ?? '')
|
||||
const dep = emptyWaypoint(src.day_id ?? '')
|
||||
dep.airport = airportFromEndpoint(from)
|
||||
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
|
||||
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
|
||||
dep.airline = meta.airline ?? ''
|
||||
dep.flight_number = meta.flight_number ?? ''
|
||||
dep.seat = meta.seat ?? ''
|
||||
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
|
||||
const arr = emptyWaypoint(src.end_day_id ?? src.day_id ?? '')
|
||||
arr.airport = airportFromEndpoint(to)
|
||||
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
|
||||
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
|
||||
wps = [dep, arr]
|
||||
}
|
||||
setWaypoints(wps)
|
||||
@@ -224,7 +253,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setToPick({})
|
||||
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||
}
|
||||
}, [isOpen, reservation, selectedDayId, budgetItems])
|
||||
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
|
||||
|
||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
@@ -328,6 +357,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
// Imported booking → auto-create the linked cost from the parsed price (what the
|
||||
// old direct import did). Only on create (not edit) and only when there's a price.
|
||||
if (!reservation && prefill && isBudgetEnabled) {
|
||||
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
|
||||
const price = Number(pmeta.price)
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
;(payload as Record<string, unknown>).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
|
||||
}
|
||||
}
|
||||
const saved = await onSave(payload)
|
||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
||||
for (const file of pendingFiles) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { BookingImportPreviewItem, Reservation, ReservationEndpoint } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* A pre-fill draft for the reservation/transport edit modals built from a parsed
|
||||
* booking-import item. Carries the normal reservation fields the modals read for
|
||||
* their form, plus the import-only `_venue`/`_accommodation` the hotel path needs
|
||||
* to suggest a place and a day range. It has no `id` — the modal stays in
|
||||
* "create" mode and the user reviews/edits before it is ever persisted.
|
||||
*/
|
||||
export interface BookingReviewDraft extends Omit<Partial<Reservation>, 'metadata' | 'endpoints'> {
|
||||
/** Type-specific extras (airline, flight_number, check_in_time, price, …) as an object. */
|
||||
metadata?: Record<string, unknown> | null
|
||||
endpoints?: ReservationEndpoint[]
|
||||
/** Parsed venue (auto-created place candidate) — hotel/restaurant/event. */
|
||||
_venue?: BookingImportPreviewItem['_venue']
|
||||
/** Parsed check-in/out + confirmation — hotels only. */
|
||||
_accommodation?: BookingImportPreviewItem['_accommodation']
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a parsed booking item onto the shape the edit modals pre-fill from. Pure
|
||||
* (no I/O). Transport items keep their geocoded endpoints; venue/accommodation
|
||||
* ride along untouched so the hotel modal can match a place by name (or create
|
||||
* one from the reviewed address on save).
|
||||
*/
|
||||
export function parsedItemToDraft(item: BookingImportPreviewItem): BookingReviewDraft {
|
||||
return {
|
||||
type: item.type,
|
||||
title: item.title,
|
||||
status: 'pending',
|
||||
reservation_time: item.reservation_time ?? null,
|
||||
reservation_end_time: item.reservation_end_time ?? null,
|
||||
location: item.location ?? item._venue?.address ?? item._venue?.name ?? null,
|
||||
confirmation_number: item.confirmation_number ?? null,
|
||||
notes: null,
|
||||
metadata: (item.metadata as Record<string, unknown> | undefined) ?? null,
|
||||
endpoints: (item.endpoints ?? []) as ReservationEndpoint[],
|
||||
_venue: item._venue,
|
||||
_accommodation: item._accommodation,
|
||||
}
|
||||
}
|
||||
|
||||
/** Transport types route to the TransportModal; everything else to the ReservationModal. */
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
|
||||
export function isTransportItem(item: BookingImportPreviewItem): boolean {
|
||||
return TRANSPORT_TYPES.has(item.type)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||
import { TransportModal } from '../components/Planner/TransportModal'
|
||||
import BookingImportModal from '../components/Planner/BookingImportModal'
|
||||
import { useBackgroundTasksStore } from '../store/backgroundTasksStore'
|
||||
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
|
||||
// MemoriesPanel moved to Journey addon
|
||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||
@@ -195,6 +196,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
|
||||
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
|
||||
@@ -210,6 +212,23 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
|
||||
} = useTripPlanner()
|
||||
|
||||
// Bridge: when a finished background import is sent here for review (the user hit
|
||||
// "review" in the background widget, on this or any page), open the per-item flow.
|
||||
const bgTasks = useBackgroundTasksStore((s) => s.tasks)
|
||||
const dismissBgTask = useBackgroundTasksStore((s) => s.dismiss)
|
||||
useEffect(() => {
|
||||
const task = bgTasks.find(
|
||||
(tk) => tk.tripId === String(tripId) && tk.status === 'done' && tk.reviewRequested && !tk.consumed,
|
||||
)
|
||||
if (task && task.items && task.items.length > 0) {
|
||||
// Hand the items to the review flow and clear the widget entry — once the user
|
||||
// hit "review", the background card has done its job.
|
||||
const items = task.items
|
||||
dismissBgTask(task.id)
|
||||
startImportReview(items)
|
||||
}
|
||||
}, [bgTasks, tripId, startImportReview, dismissBgTask])
|
||||
|
||||
const poi = usePoiExplore()
|
||||
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
@@ -699,8 +718,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<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} onOpenExpense={openBookingExpense} />
|
||||
{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)} onOpenExpense={openBookingExpense} />}
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} 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} onOpenExpense={openBookingExpense} />
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
|
||||
{bookingExpense && (
|
||||
<ExpenseModal
|
||||
tripId={tripId}
|
||||
@@ -713,7 +732,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }}
|
||||
/>
|
||||
)}
|
||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} />
|
||||
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { getCached, fetchPhoto } from '../../services/photoService'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
|
||||
import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
|
||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -158,6 +160,12 @@ export function useTripPlanner() {
|
||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
||||
// Review-before-save import: each parsed item pre-fills the normal edit modal so
|
||||
// the user checks/fixes it, then saves. A ref drives the queue (no stale closures).
|
||||
const [reservationPrefill, setReservationPrefill] = useState<BookingReviewDraft | null>(null)
|
||||
const [transportPrefill, setTransportPrefill] = useState<BookingReviewDraft | null>(null)
|
||||
const [importReviewActive, setImportReviewActive] = useState(false)
|
||||
const importQueueRef = useRef<BookingImportPreviewItem[]>([])
|
||||
// Manual route planning: off by default, toggled from the day-plan footer. Mode
|
||||
// (driving/walking) is per-session and selects which travel time the connectors show.
|
||||
const [routeShown, setRouteShown] = useState(false)
|
||||
@@ -578,6 +586,13 @@ export function useTripPlanner() {
|
||||
|
||||
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
||||
try {
|
||||
// Imported hotel with a reviewed address but no existing place picked: match
|
||||
// an existing place by name, else geocode the address and create one, then link it.
|
||||
const acc = (data as Record<string, any>).create_accommodation
|
||||
if (data.type === 'hotel' && acc && acc.venue && !acc.place_id) {
|
||||
acc.place_id = (await resolveImportedPlace(acc.venue)) ?? undefined
|
||||
delete acc.venue
|
||||
}
|
||||
if (editingReservation) {
|
||||
// Don't force a day here. The old code pinned it to the (often empty)
|
||||
// selected day, which dropped the booking out of the Plan; preserving the
|
||||
@@ -635,6 +650,78 @@ export function useTripPlanner() {
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// ── Review-before-save booking import ───────────────────────────────────────
|
||||
// Match an existing trip place by name, else geocode the reviewed address and
|
||||
// create one. Returns the place id (or null if even creation failed).
|
||||
const resolveImportedPlace = async (venue: { name?: string; address?: string | null }): Promise<number | null> => {
|
||||
const name = (venue.name || '').trim()
|
||||
const n = name.toLowerCase()
|
||||
if (n) {
|
||||
const existing = places.find(p => p.name?.trim().toLowerCase() === n)
|
||||
?? places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
|
||||
if (existing) return existing.id
|
||||
}
|
||||
let lat: number | null = null
|
||||
let lng: number | null = null
|
||||
let address: string | null = venue.address ?? null
|
||||
try {
|
||||
const query = venue.address ? `${name} ${venue.address}`.trim() : name
|
||||
if (query) {
|
||||
const res = await mapsApi.search(query)
|
||||
const hit = res?.places?.[0] as { lat?: number; lng?: number; address?: string } | undefined
|
||||
if (hit && hit.lat != null && hit.lng != null) {
|
||||
lat = hit.lat; lng = hit.lng
|
||||
if (!address && hit.address) address = hit.address
|
||||
}
|
||||
}
|
||||
} catch { /* geocode failure is non-fatal — create the place without coords */ }
|
||||
try {
|
||||
const place = await placesApi.create(tripId, { name: name || address || 'Accommodation', lat, lng, address } as never)
|
||||
return (place as { id?: number })?.id ?? null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// Open the right edit modal for a parsed item, pre-filled, in create mode.
|
||||
const openImportItem = (item: BookingImportPreviewItem) => {
|
||||
const draft = parsedItemToDraft(item)
|
||||
if (isTransportItem(item)) {
|
||||
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
|
||||
setEditingTransport(null); setTransportModalDayId(null)
|
||||
setTransportPrefill(draft); setShowTransportModal(true)
|
||||
} else {
|
||||
setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null)
|
||||
setEditingReservation(null)
|
||||
setReservationPrefill(draft); setShowReservationModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
const startImportReview = (items: BookingImportPreviewItem[]) => {
|
||||
if (!items.length) return
|
||||
importQueueRef.current = items.slice(1)
|
||||
setImportReviewActive(true)
|
||||
openImportItem(items[0])
|
||||
}
|
||||
|
||||
// Called when a reviewed item's modal closes (saved or skipped): open the next,
|
||||
// or finish the review session and refresh accommodations.
|
||||
const advanceImportReview = () => {
|
||||
const queue = importQueueRef.current
|
||||
if (queue.length > 0) {
|
||||
importQueueRef.current = queue.slice(1)
|
||||
openImportItem(queue[0])
|
||||
return
|
||||
}
|
||||
importQueueRef.current = []
|
||||
setImportReviewActive(false)
|
||||
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
|
||||
setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null)
|
||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||
// Imported bookings auto-create their linked costs server-side, but the saving client
|
||||
// suppresses its own budget:created echo (X-Socket-Id) — so reload the budget items here
|
||||
// to surface those expenses without a manual page refresh.
|
||||
tripActions.loadBudgetItems?.(tripId)
|
||||
}
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
// Build placeId → order-number map from the selected day's assignments
|
||||
@@ -693,6 +780,7 @@ export function useTripPlanner() {
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
|
||||
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { BookingImportPreviewItem } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Tracks booking-import parses that run in the BACKGROUND (the async endpoint).
|
||||
* The upload modal closes the moment a parse starts and adds a task here; the
|
||||
* server pushes import:progress / import:done / import:error over the user's
|
||||
* WebSocket (which reaches every page), and the global BackgroundTasksWidget
|
||||
* renders the list. The trip page turns a finished task into the review flow.
|
||||
*
|
||||
* Persisted (minimal): the server keeps the job for ~10 min and exposes a status
|
||||
* endpoint, so a reload mid-parse must NOT drop the widget — we persist the running
|
||||
* (and finished-but-unreviewed) tasks by id and the widget re-fetches their status
|
||||
* on mount. We deliberately persist neither the parsed `items` (re-fetched) nor the
|
||||
* transient review flags (so a reload never auto-reopens the review flow).
|
||||
*/
|
||||
export interface BackgroundImportTask {
|
||||
id: string // server job id
|
||||
tripId: string
|
||||
label: string // file name(s) being parsed
|
||||
status: 'running' | 'done' | 'error'
|
||||
done: number
|
||||
total: number
|
||||
items?: BookingImportPreviewItem[]
|
||||
warnings?: string[]
|
||||
error?: string
|
||||
reviewRequested?: boolean // user clicked "review" — the trip page consumes it
|
||||
consumed?: boolean // review has been handed to the trip page
|
||||
}
|
||||
|
||||
interface BackgroundTasksState {
|
||||
tasks: BackgroundImportTask[]
|
||||
addTask: (task: { id: string; tripId: string; label: string; total: number }) => void
|
||||
setProgress: (id: string, tripId: string, done: number, total: number) => void
|
||||
setDone: (id: string, tripId: string, items: BookingImportPreviewItem[], warnings: string[]) => void
|
||||
setError: (id: string, tripId: string, error: string) => void
|
||||
requestReview: (id: string) => void
|
||||
markConsumed: (id: string) => void
|
||||
dismiss: (id: string) => void
|
||||
}
|
||||
|
||||
export const useBackgroundTasksStore = create<BackgroundTasksState>()(
|
||||
persist(
|
||||
(set) => {
|
||||
/** Update an existing task by id, or insert a fresh one (events can arrive before addTask). */
|
||||
const upsert = (id: string, tripId: string, patch: Partial<BackgroundImportTask>) =>
|
||||
set((state) => {
|
||||
const idx = state.tasks.findIndex((t) => t.id === id)
|
||||
if (idx === -1) {
|
||||
const base: BackgroundImportTask = { id, tripId, label: 'Import', status: 'running', done: 0, total: 1 }
|
||||
return { tasks: [...state.tasks, { ...base, ...patch }] }
|
||||
}
|
||||
const tasks = state.tasks.slice()
|
||||
tasks[idx] = { ...tasks[idx], ...patch }
|
||||
return { tasks }
|
||||
})
|
||||
|
||||
return {
|
||||
tasks: [],
|
||||
addTask: ({ id, tripId, label, total }) => upsert(id, tripId, { label, total, status: 'running', done: 0 }),
|
||||
setProgress: (id, tripId, done, total) => upsert(id, tripId, { done, total, status: 'running' }),
|
||||
setDone: (id, tripId, items, warnings) => upsert(id, tripId, { status: 'done', items, warnings, done: items?.length ?? 0 }),
|
||||
setError: (id, tripId, error) => upsert(id, tripId, { status: 'error', error }),
|
||||
requestReview: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, reviewRequested: true } : t)) })),
|
||||
markConsumed: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, consumed: true, reviewRequested: false } : t)) })),
|
||||
dismiss: (id) => set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) })),
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'trek.bg-import-tasks',
|
||||
// Persist only what survives a reload usefully: the job id/trip/label and a coarse
|
||||
// status. The widget re-fetches each job's real status (and parsed items) on mount,
|
||||
// so we keep neither the heavy `items`/`warnings` nor the transient review flags —
|
||||
// that also guarantees a reload never re-opens the review flow on its own.
|
||||
partialize: (state) => ({
|
||||
tasks: state.tasks
|
||||
.filter((t) => !t.consumed && t.status !== 'error')
|
||||
.map((t) => ({ id: t.id, tripId: t.tripId, label: t.label, status: t.status, done: t.done, total: t.total })),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -120,6 +120,13 @@ export interface Settings {
|
||||
mapbox_style?: string
|
||||
mapbox_3d_enabled?: boolean
|
||||
mapbox_quality_mode?: boolean
|
||||
// AI booking-import fallback (per-user config; used when the admin has not set
|
||||
// instance-wide config on the llm_parsing addon). llm_api_key is masked on read.
|
||||
llm_provider?: 'local' | 'openai' | 'anthropic'
|
||||
llm_model?: string
|
||||
llm_base_url?: string
|
||||
llm_multimodal?: boolean
|
||||
llm_api_key?: string
|
||||
}
|
||||
|
||||
export interface AssignmentsMap {
|
||||
|
||||
Generated
+260
-51
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@trek/root",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"dependencies": {
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
@@ -4275,6 +4275,205 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
|
||||
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.80",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
|
||||
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
|
||||
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
|
||||
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
|
||||
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
|
||||
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.80",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
|
||||
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
@@ -5691,8 +5890,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5706,8 +5904,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5721,8 +5918,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5736,8 +5932,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5751,8 +5946,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5766,8 +5960,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.62.0",
|
||||
@@ -5781,8 +5974,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.62.0",
|
||||
@@ -5796,8 +5988,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -5811,8 +6002,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -5839,8 +6029,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -5854,8 +6043,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -5869,8 +6057,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -5884,8 +6071,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -5899,8 +6085,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -5914,8 +6099,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -5929,8 +6113,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -5944,8 +6127,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.62.0",
|
||||
@@ -5972,8 +6154,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.62.0",
|
||||
@@ -5987,8 +6168,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -6002,8 +6182,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -6017,8 +6196,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.62.0",
|
||||
@@ -6032,8 +6210,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.62.0",
|
||||
@@ -6047,8 +6224,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.3.0",
|
||||
@@ -15155,6 +15331,38 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-parse": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
|
||||
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"pdfjs-dist": "5.4.296"
|
||||
},
|
||||
"bin": {
|
||||
"pdf-parse": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.16.0 <21 || >=22.3.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.296",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
|
||||
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.16.0 || >=22.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.80"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -20543,7 +20751,7 @@
|
||||
},
|
||||
"server": {
|
||||
"name": "@trek/server",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
@@ -20566,6 +20774,7 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
@@ -20900,7 +21109,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@trek/shared",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^3.15.0",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --require tsconfig-paths/register dist/index.js",
|
||||
@@ -42,6 +42,7 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^9.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
|
||||
@@ -8,6 +8,7 @@ export const ADDON_IDS = {
|
||||
COLLAB: 'collab',
|
||||
JOURNEY: 'journey',
|
||||
AIRTRAIL: 'airtrail',
|
||||
LLM_PARSING: 'llm_parsing',
|
||||
} as const;
|
||||
|
||||
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
|
||||
|
||||
@@ -104,6 +104,7 @@ function seedAddons(db: Database.Database): void {
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
||||
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your self-hosted AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
|
||||
{ id: 'llm_parsing', name: 'AI Parsing', description: 'LLM fallback for booking imports kitinerary cannot read', type: 'integration', icon: 'Sparkles', enabled: 0, sort_order: 15 },
|
||||
];
|
||||
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Param,
|
||||
Headers,
|
||||
@@ -15,7 +16,9 @@ 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';
|
||||
import { ImportJobsService } from './import-jobs.service';
|
||||
import { bookingImportModeSchema } from '@trek/shared';
|
||||
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode } from '@trek/shared';
|
||||
|
||||
const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']);
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
@@ -29,7 +32,10 @@ const UPLOAD = {
|
||||
@Controller('api/trips/:tripId/reservations/import')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class BookingImportController {
|
||||
constructor(private readonly bookingImport: BookingImportService) {}
|
||||
constructor(
|
||||
private readonly bookingImport: BookingImportService,
|
||||
private readonly importJobs: ImportJobsService,
|
||||
) {}
|
||||
|
||||
private requireTrip(tripId: string, user: User) {
|
||||
const trip = this.bookingImport.verifyTripAccess(tripId, user.id);
|
||||
@@ -43,6 +49,31 @@ export class BookingImportController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared validation for both the sync and async import endpoints; returns the parsed mode. */
|
||||
private validateImport(tripId: string, user: User, files: Express.Multer.File[] | undefined, rawMode?: string): BookingImportMode {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
|
||||
const modeResult = bookingImportModeSchema.safeParse(rawMode ?? 'no-ai');
|
||||
if (!modeResult.success) throw new HttpException({ error: 'Invalid mode' }, 400);
|
||||
const mode = modeResult.data;
|
||||
|
||||
if (mode === 'force-ai' && !this.bookingImport.aiAvailable(user.id)) {
|
||||
throw new HttpException({ error: 'AI parsing is not configured' }, 409);
|
||||
}
|
||||
if (mode === 'no-ai' && !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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/trips/:tripId/reservations/import/booking
|
||||
* Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT).
|
||||
@@ -54,28 +85,42 @@ export class BookingImportController {
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
@Body('mode') rawMode?: string,
|
||||
): Promise<BookingImportPreviewResponse> {
|
||||
const mode = this.validateImport(tripId, user, files, rawMode);
|
||||
return this.bookingImport.preview(files!, mode, user.id);
|
||||
}
|
||||
|
||||
if (!this.bookingImport.isAvailable()) {
|
||||
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
|
||||
}
|
||||
/**
|
||||
* POST /api/trips/:tripId/reservations/import/booking/async
|
||||
* Same input as /booking, but returns a job id immediately and parses in the
|
||||
* background. Progress + completion are pushed over the user's WebSocket
|
||||
* (import:progress / import:done / import:error). Lets the upload modal close at
|
||||
* once and a background widget track the work while the user keeps navigating.
|
||||
*/
|
||||
@Post('booking/async')
|
||||
@UseInterceptors(FilesInterceptor('files', MAX_FILES, UPLOAD))
|
||||
async previewAsync(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[] | undefined,
|
||||
@Body('mode') rawMode?: string,
|
||||
): Promise<{ jobId: string }> {
|
||||
const mode = this.validateImport(tripId, user, files, rawMode);
|
||||
const jobId = this.importJobs.start(tripId, files!, mode, user.id);
|
||||
return { jobId };
|
||||
}
|
||||
|
||||
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;
|
||||
/**
|
||||
* GET /api/trips/:tripId/reservations/import/jobs/:jobId
|
||||
* Poll a background import job — recovery path for a client that missed the
|
||||
* WebSocket push (navigation, reconnect). 404 once the job has expired.
|
||||
*/
|
||||
@Get('jobs/:jobId')
|
||||
async jobStatus(@CurrentUser() user: User, @Param('jobId') jobId: string) {
|
||||
const job = this.importJobs.get(jobId, user.id);
|
||||
if (!job) throw new HttpException({ error: 'Job not found' }, 404);
|
||||
return { status: job.status, done: job.done, total: job.total, result: job.result, error: job.error };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BookingImportController } from './booking-import.controller';
|
||||
import { BookingImportService } from './booking-import.service';
|
||||
import { ImportJobsService } from './import-jobs.service';
|
||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
||||
import { FeaturesController } from './features.controller';
|
||||
import { LlmParseModule } from '../llm-parse/llm-parse.module';
|
||||
|
||||
@Module({
|
||||
imports: [LlmParseModule],
|
||||
controllers: [BookingImportController, FeaturesController],
|
||||
providers: [BookingImportService, KitineraryExtractorService],
|
||||
providers: [BookingImportService, KitineraryExtractorService, ImportJobsService],
|
||||
})
|
||||
export class BookingImportModule {}
|
||||
|
||||
@@ -4,30 +4,47 @@ import { checkPermission } from '../../services/permissions';
|
||||
import { verifyTripAccess } from '../../services/tripAccess';
|
||||
import { createReservation } from '../../services/reservationService';
|
||||
import { createPlace } from '../../services/placeService';
|
||||
import { createBudgetItem } from '../../services/budgetService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { searchNominatim } from '../../services/mapsService';
|
||||
import { db } from '../../db/database';
|
||||
import type { User } from '../../types';
|
||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
||||
import { LlmParseService } from '../llm-parse/llm-parse.service';
|
||||
import { mapReservations } from './kitinerary-mapper';
|
||||
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, Reservation } from '@trek/shared';
|
||||
import type { ParsedBookingItem } from './kitinerary.types';
|
||||
import { typeToCostCategory } from '@trek/shared';
|
||||
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode, BookingImportFileReport, Reservation } from '@trek/shared';
|
||||
import type { ParsedBookingItem, KiReservation } 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;
|
||||
const exact = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
||||
if (exact) return exact.id;
|
||||
// Clamp to the nearest trip day so an out-of-range / unmatched check-in still
|
||||
// resolves and the accommodation row is inserted.
|
||||
const nearest = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
||||
return nearest?.id ?? null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingImportService {
|
||||
constructor(private readonly extractor: KitineraryExtractorService) {}
|
||||
constructor(
|
||||
private readonly extractor: KitineraryExtractorService,
|
||||
private readonly llmParse: LlmParseService,
|
||||
) {}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.extractor.isAvailable();
|
||||
}
|
||||
|
||||
/** True when the LLM fallback is enabled and configured for this user. */
|
||||
aiAvailable(userId: number): boolean {
|
||||
return this.llmParse.isAvailable(userId);
|
||||
}
|
||||
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return verifyTripAccess(tripId, userId);
|
||||
}
|
||||
@@ -37,37 +54,69 @@ export class BookingImportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse uploaded files through kitinerary-extractor and return a preview list.
|
||||
* Does NOT persist anything.
|
||||
* Parse uploaded files and return a preview list. Does NOT persist anything.
|
||||
* Runs kitinerary first; depending on `mode`, falls back to the LLM:
|
||||
* - no-ai: kitinerary only
|
||||
* - fallback-on-empty: LLM for files kitinerary returns nothing for
|
||||
* - force-ai: LLM on every file (kitinerary skipped)
|
||||
* LLM-derived items are flagged needs_review. Per-file AI usage is reported.
|
||||
*/
|
||||
async preview(files: Express.Multer.File[]): Promise<BookingImportPreviewResponse> {
|
||||
if (!this.extractor.isAvailable()) {
|
||||
async preview(
|
||||
files: Express.Multer.File[],
|
||||
mode: BookingImportMode,
|
||||
userId: number,
|
||||
onProgress?: (done: number, total: number, fileName: string) => void,
|
||||
): Promise<BookingImportPreviewResponse> {
|
||||
const kitineraryAvailable = this.extractor.isAvailable();
|
||||
const aiAvailable = this.llmParse.isAvailable(userId);
|
||||
if (!kitineraryAvailable && !aiAvailable) {
|
||||
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
|
||||
}
|
||||
|
||||
const allItems: ParsedBookingItem[] = [];
|
||||
const allWarnings: string[] = [];
|
||||
const fileReports: BookingImportFileReport[] = [];
|
||||
|
||||
let processed = 0;
|
||||
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;
|
||||
let kiItems: KiReservation[] = [];
|
||||
let aiUsed = false;
|
||||
|
||||
// Stage 1: kitinerary (skipped entirely when forcing AI).
|
||||
if (mode !== 'force-ai' && kitineraryAvailable) {
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 1b: LLM fallback.
|
||||
const runLlm = aiAvailable && (mode === 'force-ai' || (mode === 'fallback-on-empty' && kiItems.length === 0));
|
||||
if (runLlm) {
|
||||
aiUsed = true;
|
||||
const llm = await this.llmParse.parse({ buffer: file.buffer, originalName: file.originalname }, userId);
|
||||
kiItems = llm.kiItems;
|
||||
allWarnings.push(...llm.warnings);
|
||||
}
|
||||
|
||||
fileReports.push({ fileName: file.originalname, aiAvailable, aiUsed });
|
||||
|
||||
if (kiItems.length === 0) {
|
||||
allWarnings.push(`${file.originalname}: no reservations found`);
|
||||
continue;
|
||||
} else {
|
||||
const { items, warnings } = mapReservations(kiItems, file.originalname);
|
||||
// LLM extraction is less certain than kitinerary — always flag for review.
|
||||
if (aiUsed) for (const it of items) it.needs_review = true;
|
||||
allItems.push(...items);
|
||||
allWarnings.push(...warnings);
|
||||
}
|
||||
|
||||
const { items, warnings } = mapReservations(kiItems, file.originalname);
|
||||
allItems.push(...items);
|
||||
allWarnings.push(...warnings);
|
||||
// Report per-file progress so a background import can drive a live widget.
|
||||
onProgress?.(++processed, files.length, file.originalname);
|
||||
}
|
||||
|
||||
return { items: allItems, warnings: allWarnings };
|
||||
return { items: allItems, warnings: allWarnings, files: fileReports };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +175,28 @@ export class BookingImportService {
|
||||
broadcast(tripId, 'place:created', { place }, socketId);
|
||||
}
|
||||
|
||||
// Geocode transport endpoints (stations/stops/terminals/rental desks) that
|
||||
// arrived without coords, so the route draws and map pins appear. The LLM
|
||||
// and kitinerary rarely supply geo for non-airport endpoints.
|
||||
if (Array.isArray(reservationData.endpoints)) {
|
||||
for (const ep of reservationData.endpoints) {
|
||||
if ((ep.lat == null || ep.lng == null) && ep.name) {
|
||||
try {
|
||||
const hit = (await searchNominatim(ep.name))[0];
|
||||
if (hit?.lat != null && hit?.lng != null) {
|
||||
ep.lat = hit.lat;
|
||||
ep.lng = hit.lng;
|
||||
}
|
||||
} catch {
|
||||
// geocoding failure is non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
// Persist only coord'd endpoints (reservation_endpoints needs lat/lng);
|
||||
// ungeocodable ones still appeared in the preview's From→To.
|
||||
reservationData.endpoints = reservationData.endpoints.filter((ep) => ep.lat != null && ep.lng != null);
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -154,6 +225,33 @@ export class BookingImportService {
|
||||
broadcast(tripId, 'accommodation:created', {}, socketId);
|
||||
}
|
||||
|
||||
// Turn an extracted price into a real linked cost (Costs addon), so the
|
||||
// booking shows up as an expense — not just a price in metadata.
|
||||
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
|
||||
const meta =
|
||||
reservationData.metadata && typeof reservationData.metadata === 'object'
|
||||
? (reservationData.metadata as Record<string, unknown>)
|
||||
: null;
|
||||
const price = meta && meta.price != null ? Number(meta.price) : NaN;
|
||||
if (Number.isFinite(price) && price > 0) {
|
||||
try {
|
||||
const budgetItem = createBudgetItem(tripId, {
|
||||
category: typeToCostCategory(item.type),
|
||||
name: item.title,
|
||||
total_price: price,
|
||||
currency: meta && typeof meta.priceCurrency === 'string' ? meta.priceCurrency : null,
|
||||
reservation_id: reservation.id,
|
||||
});
|
||||
broadcast(tripId, 'budget:created', { item: budgetItem }, socketId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[booking-import] Failed to create cost for "${item.title}":`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
created.push(reservation);
|
||||
} catch (err) {
|
||||
console.error(`[booking-import] Failed to create reservation "${item.title}":`, err instanceof Error ? err.message : err);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
|
||||
/** Exposes server feature flags consumed by the frontend to show/hide optional UI. */
|
||||
@Controller('api/health')
|
||||
@@ -10,6 +12,9 @@ export class FeaturesController {
|
||||
features() {
|
||||
return {
|
||||
bookingImport: this.extractor.isAvailable(),
|
||||
// Addon-level flag (per-user config availability is reported per-file in
|
||||
// the preview response). Drives whether the client shows AI affordances.
|
||||
aiParsing: isAddonEnabled(ADDON_IDS.LLM_PARSING),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { broadcastToUser } from '../../websocket';
|
||||
import { BookingImportService } from './booking-import.service';
|
||||
import type { BookingImportMode, BookingImportPreviewResponse } from '@trek/shared';
|
||||
|
||||
type JobStatus = 'running' | 'done' | 'error';
|
||||
|
||||
interface ImportJob {
|
||||
id: string;
|
||||
tripId: string;
|
||||
userId: number;
|
||||
status: JobStatus;
|
||||
done: number;
|
||||
total: number;
|
||||
result?: BookingImportPreviewResponse;
|
||||
error?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// Keep a finished job around briefly so a client that missed the WebSocket push
|
||||
// (navigation, reconnect) can still GET its result.
|
||||
const JOB_TTL_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* Runs a booking-import parse OFF the request: the controller returns a job id
|
||||
* immediately, the parse continues here, and progress/completion are pushed to the
|
||||
* user's sockets via `broadcastToUser` (which reaches them on ANY page, not just the
|
||||
* trip room). This is what lets the upload modal close at once and a background widget
|
||||
* track the work while the user keeps navigating. The actual parsing is the same
|
||||
* `BookingImportService.preview` the synchronous endpoint uses.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ImportJobsService {
|
||||
private readonly jobs = new Map<string, ImportJob>();
|
||||
/** Tail of each user's job chain — parses run one at a time per user, not all at once. */
|
||||
private readonly chains = new Map<number, Promise<void>>();
|
||||
|
||||
constructor(private readonly bookingImport: BookingImportService) {}
|
||||
|
||||
/** Create a job and queue it behind the user's other parses; returns the job id at once. */
|
||||
start(tripId: string, files: Express.Multer.File[], mode: BookingImportMode, userId: number): string {
|
||||
const id = randomUUID();
|
||||
const job: ImportJob = { id, tripId, userId, status: 'running', done: 0, total: files.length, createdAt: Date.now() };
|
||||
this.jobs.set(id, job);
|
||||
// Chain onto the user's previous parse so they run sequentially (one CPU-heavy
|
||||
// inference at a time), while the request returns immediately.
|
||||
const prev = this.chains.get(userId) ?? Promise.resolve();
|
||||
const next = prev.then(() => this.run(job, files, mode)).catch(() => {});
|
||||
this.chains.set(userId, next);
|
||||
void next.finally(() => {
|
||||
if (this.chains.get(userId) === next) this.chains.delete(userId);
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
get(id: string, userId: number): ImportJob | undefined {
|
||||
const job = this.jobs.get(id);
|
||||
return job && job.userId === userId ? job : undefined;
|
||||
}
|
||||
|
||||
private async run(job: ImportJob, files: Express.Multer.File[], mode: BookingImportMode): Promise<void> {
|
||||
this.push(job, 'import:progress', { status: 'running', done: 0, total: job.total });
|
||||
try {
|
||||
const result = await this.bookingImport.preview(files, mode, job.userId, (done, total, fileName) => {
|
||||
job.done = done;
|
||||
this.push(job, 'import:progress', { status: 'running', done, total, fileName });
|
||||
});
|
||||
job.status = 'done';
|
||||
job.result = result;
|
||||
this.push(job, 'import:done', { result });
|
||||
} catch (err) {
|
||||
job.status = 'error';
|
||||
job.error = err instanceof Error ? err.message : String(err);
|
||||
this.push(job, 'import:error', { message: job.error });
|
||||
} finally {
|
||||
const id = job.id;
|
||||
setTimeout(() => this.jobs.delete(id), JOB_TTL_MS).unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
private push(job: ImportJob, type: string, payload: Record<string, unknown>): void {
|
||||
broadcastToUser(job.userId, { type, jobId: job.id, tripId: job.tripId, ...payload });
|
||||
}
|
||||
}
|
||||
@@ -189,8 +189,9 @@ function mapTrain(r: KiReservation, source: ParsedBookingItem['source']): Parsed
|
||||
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 });
|
||||
// Push named endpoints even without coords — confirm() geocodes them later.
|
||||
if (t.departureStation?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate });
|
||||
if (t.arrivalStation?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, timezone: null, local_time: arrTime, local_date: arrDate });
|
||||
|
||||
return {
|
||||
type: 'train',
|
||||
@@ -220,10 +221,10 @@ function mapBus(r: KiReservation, source: ParsedBookingItem['source']): ParsedBo
|
||||
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 });
|
||||
if (b.departureBusStop?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate });
|
||||
if (b.arrivalBusStop?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, 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 };
|
||||
return { type: 'bus', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, metadata: busId ? { bus_number: busId } : undefined, endpoints, needs_review: endpoints.length < 2, source };
|
||||
}
|
||||
|
||||
function mapBoat(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
||||
@@ -240,10 +241,10 @@ function mapBoat(r: KiReservation, source: ParsedBookingItem['source']): ParsedB
|
||||
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 });
|
||||
if (b.departureBoatTerminal?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate });
|
||||
if (b.arrivalBoatTerminal?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, 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 };
|
||||
return { type: 'cruise', 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 mapLodging(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
||||
@@ -287,10 +288,31 @@ function mapRentalCar(r: KiReservation, source: ParsedBookingItem['source']): Pa
|
||||
const title = [company, carName].filter(Boolean).join(' — ') || 'Rental Car';
|
||||
|
||||
const pickup = r.pickupLocation as KiReservation['pickupLocation'];
|
||||
const dropoff = r.dropoffLocation as KiReservation['dropoffLocation'];
|
||||
const pc = coords(pickup?.geo);
|
||||
const drc = coords(dropoff?.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 };
|
||||
// Pickup → return as from/to endpoints (coords optional; confirm() geocodes).
|
||||
const { date: puDate, time: puTime } = splitIso(r.pickupTime);
|
||||
const { date: doDate, time: doTime } = splitIso(r.dropoffTime);
|
||||
const endpoints: ParsedEndpoint[] = [];
|
||||
if (pickup?.name) endpoints.push({ role: 'from', sequence: 0, name: pickup.name, code: null, lat: pc?.lat ?? null, lng: pc?.lng ?? null, timezone: null, local_time: puTime, local_date: puDate });
|
||||
if (dropoff?.name) endpoints.push({ role: 'to', sequence: 1, name: dropoff.name, code: null, lat: drc?.lat ?? null, lng: drc?.lng ?? null, timezone: null, local_time: doTime, local_date: doDate });
|
||||
|
||||
return {
|
||||
type: 'car',
|
||||
title,
|
||||
reservation_time: toIsoString(r.pickupTime),
|
||||
reservation_end_time: toIsoString(r.dropoffTime),
|
||||
confirmation_number: r.reservationNumber ?? null,
|
||||
location: formatAddress(pickup?.address) ?? pickup?.name ?? null,
|
||||
...(company ? { metadata: { rental_company: company } } : {}),
|
||||
endpoints,
|
||||
needs_review: endpoints.length < 2,
|
||||
...(venue ? { _venue: venue } : {}),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
||||
@@ -299,15 +321,42 @@ function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): Parsed
|
||||
|
||||
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;
|
||||
const venue: ParsedVenue | undefined = loc?.name ? { name: loc.name, ...(c ?? {}), address: formatAddress(loc.address) ?? undefined, website: loc.url ?? undefined, phone: loc.telephone ?? 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 };
|
||||
return { type: 'event', title: e.name, reservation_time: toIsoString(e.startDate ?? r.startTime), reservation_end_time: toIsoString(e.endDate ?? r.endTime), confirmation_number: r.reservationNumber ?? null, location: loc ? (formatAddress(loc.address) ?? loc.name ?? null) : null, ...(venue ? { _venue: venue } : {}), source };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Merge seat/class/platform/price into an item's metadata (type-agnostic).
|
||||
* Models name these inconsistently and sometimes nest them under reservationFor,
|
||||
* so check both levels and common aliases. The item's own metadata wins. */
|
||||
function applyCommonMeta(item: ParsedBookingItem, r: KiReservation): ParsedBookingItem {
|
||||
const rf = (r.reservationFor && typeof r.reservationFor === 'object' ? r.reservationFor : {}) as Record<string, unknown>;
|
||||
const pick = (...keys: string[]): unknown => {
|
||||
for (const k of keys) {
|
||||
const v = (r as Record<string, unknown>)[k] ?? rf[k];
|
||||
if (v != null && v !== '') return v;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const m: Record<string, unknown> = {};
|
||||
const seat = pick('seat', 'seatNumber');
|
||||
if (seat != null) m.seat = String(seat);
|
||||
const cls = pick('class', 'bookingClass', 'fareClass', 'serviceClass', 'seatingType');
|
||||
if (cls != null) m.class = String(cls);
|
||||
const platform = pick('platform', 'departurePlatform');
|
||||
if (platform != null) m.platform = String(platform);
|
||||
const price = pick('price', 'priceAmount', 'totalPrice', 'total');
|
||||
if (price != null) m.price = price;
|
||||
const cur = pick('priceCurrency', 'priceCurrencyISO4217Code', 'currency');
|
||||
if (cur != null) m.priceCurrency = String(cur);
|
||||
if (Object.keys(m).length) item.metadata = { ...m, ...(item.metadata ?? {}) };
|
||||
return item;
|
||||
}
|
||||
|
||||
export function mapReservations(kiItems: KiReservation[], fileName: string): { items: ParsedBookingItem[]; warnings: string[] } {
|
||||
const items: ParsedBookingItem[] = [];
|
||||
const warnings: string[] = [];
|
||||
@@ -331,7 +380,7 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i
|
||||
group.push(kiItems[++i]);
|
||||
}
|
||||
item = group.length > 1 ? mapFlightGroup(group, source) : mapFlight(r, source);
|
||||
if (item) items.push(item);
|
||||
if (item) items.push(applyCommonMeta(item, r));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -348,7 +397,7 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i
|
||||
warnings.push(`Unknown type "${r['@type']}" in ${fileName}[${i}] — skipped`);
|
||||
}
|
||||
|
||||
if (item) items.push(item);
|
||||
if (item) items.push(applyCommonMeta(item, r));
|
||||
}
|
||||
|
||||
return { items, warnings };
|
||||
|
||||
@@ -112,6 +112,8 @@ export interface KiEventVenue {
|
||||
name?: string;
|
||||
address?: string | KiAddress;
|
||||
geo?: KiGeo;
|
||||
telephone?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface KiEvent {
|
||||
@@ -134,6 +136,12 @@ export interface KiReservation {
|
||||
endTime?: KiDateTimeish;
|
||||
reservationFor?: Record<string, unknown>;
|
||||
pickupLocation?: KiEventVenue;
|
||||
dropoffLocation?: KiEventVenue;
|
||||
seat?: string;
|
||||
class?: string;
|
||||
platform?: string;
|
||||
price?: number | string;
|
||||
priceCurrency?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -143,8 +151,8 @@ export interface ParsedEndpoint {
|
||||
sequence: number;
|
||||
name: string;
|
||||
code: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
timezone: string | null;
|
||||
local_time: string | null;
|
||||
local_date: string | null;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
|
||||
|
||||
const TIMEOUT_MS = 120_000;
|
||||
const MAX_TOKENS = 8192;
|
||||
const ANTHROPIC_VERSION = '2023-06-01';
|
||||
const TOOL_NAME = 'emit_reservations';
|
||||
|
||||
/**
|
||||
* Anthropic Messages API client. Structured output via forced tool-use: a single
|
||||
* `emit_reservations` tool whose `input_schema` is the reservations schema, with
|
||||
* `tool_choice` forcing it — the documented, reliable way to get structured JSON.
|
||||
* PDFs go as native base64 `document` blocks (Anthropic reads scanned PDFs).
|
||||
* Raw fetch (no SDK) to match the codebase's HTTP style.
|
||||
*/
|
||||
export class AnthropicClient implements LlmExtractionClient {
|
||||
async extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]> {
|
||||
const base = (input.baseUrl ?? 'https://api.anthropic.com').replace(/\/+$/, '');
|
||||
const url = `${base}/v1/messages`;
|
||||
|
||||
const content: unknown[] = [];
|
||||
if (input.file) {
|
||||
content.push({
|
||||
type: 'document',
|
||||
source: { type: 'base64', media_type: input.file.mimeType, data: input.file.data.toString('base64') },
|
||||
});
|
||||
}
|
||||
content.push({
|
||||
type: 'text',
|
||||
text: input.text ? `${USER_TEXT}\n\n${input.text}` : USER_TEXT,
|
||||
});
|
||||
|
||||
const body = {
|
||||
model: input.model,
|
||||
max_tokens: MAX_TOKENS,
|
||||
system: input.prompt,
|
||||
tools: [
|
||||
{
|
||||
name: TOOL_NAME,
|
||||
description: 'Return the travel reservations extracted from the document.',
|
||||
input_schema: input.jsonSchema,
|
||||
},
|
||||
],
|
||||
tool_choice: { type: 'tool', name: TOOL_NAME },
|
||||
messages: [{ role: 'user', content }],
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': input.apiKey ?? '',
|
||||
'anthropic-version': ANTHROPIC_VERSION,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '');
|
||||
throw new Error(`Anthropic request failed (${res.status}): ${detail.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
stop_reason?: string;
|
||||
content?: { type: string; name?: string; input?: { reservations?: unknown } }[];
|
||||
};
|
||||
|
||||
if (data.stop_reason === 'refusal') {
|
||||
throw new Error('Anthropic declined to process this document');
|
||||
}
|
||||
|
||||
const toolUse = data.content?.find(b => b.type === 'tool_use' && b.name === TOOL_NAME);
|
||||
const reservations = toolUse?.input?.reservations;
|
||||
return Array.isArray(reservations) ? (reservations as Record<string, unknown>[]) : [];
|
||||
}
|
||||
}
|
||||
|
||||
const USER_TEXT = 'Extract every travel reservation from the following document as schema.org JSON-LD.';
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* NuExtract adapter for the OpenAI-compatible client.
|
||||
*
|
||||
* NuExtract (NuMind) is not an instruct model — it is fine-tuned to fill a JSON
|
||||
* *template* whose leaf values are type tokens ("verbatim-string", "date-time",
|
||||
* …). Fed a generic chat instruction it just echoes the schema back, which is
|
||||
* why a plain prompt produces garbage. Run through Ollama/llama.cpp the template
|
||||
* has to be embedded INLINE in the user message under a `# Template:` header
|
||||
* (llama.cpp ignores vLLM's chat_template_kwargs), with temperature 0.
|
||||
*
|
||||
* Rather than ask NuExtract for the nested schema.org shape (its template format
|
||||
* can't express per-@type conditional fields), we give it ONE flat union template
|
||||
* — its sweet spot — and map the flat result back into the `KiReservation` shape
|
||||
* the kitinerary mapper consumes, so the whole downstream pipeline is unchanged.
|
||||
*/
|
||||
|
||||
/** Detect a NuExtract model id (e.g. `hf.co/numind/NuExtract-2.0-2B-GGUF`, `nuextract`). */
|
||||
export function isNuExtractModel(model: string | undefined): boolean {
|
||||
return !!model && /nuextract/i.test(model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat union template covering every reservation type. NuExtract fills the
|
||||
* relevant fields and returns the rest as null, so one template serves all docs.
|
||||
*
|
||||
* Deliberately flat (a single reservation, not an array). A small NuExtract (the
|
||||
* 2B) returns an empty result when handed a nested `{ reservations: [ … ] }`
|
||||
* array-of-objects template, but extracts reliably from a single flat object —
|
||||
* so this path yields one reservation per document. Multi-segment itineraries
|
||||
* (round trips) are left to the generic instruct path (qwen/cloud), which the
|
||||
* system prompt already drives to emit every leg.
|
||||
*/
|
||||
export const NUEXTRACT_TEMPLATE = {
|
||||
type: ['flight', 'train', 'bus', 'ferry', 'car', 'hotel', 'restaurant', 'event'],
|
||||
name: 'verbatim-string',
|
||||
booking_reference: 'verbatim-string',
|
||||
operator: 'verbatim-string',
|
||||
vehicle_number: 'verbatim-string',
|
||||
// Departure/arrival double as a rental car's pick-up/return (place + time) — a
|
||||
// separate pickup_location field only tempted the model to grab a nearby form
|
||||
// label ("Location Terminal") instead of the actual depot.
|
||||
from_name: 'verbatim-string',
|
||||
from_code: 'verbatim-string',
|
||||
to_name: 'verbatim-string',
|
||||
to_code: 'verbatim-string',
|
||||
departure_time: 'date-time',
|
||||
arrival_time: 'date-time',
|
||||
address: 'verbatim-string',
|
||||
checkin_time: 'date-time',
|
||||
checkout_time: 'date-time',
|
||||
start_time: 'date-time',
|
||||
end_time: 'date-time',
|
||||
telephone: 'verbatim-string',
|
||||
website: 'verbatim-string',
|
||||
seat: 'verbatim-string',
|
||||
travel_class: 'verbatim-string',
|
||||
platform: 'verbatim-string',
|
||||
// Verbatim so we parse the localized number ourselves — asking the model for a
|
||||
// JSON number turns "1.580,22 €" (German thousands/decimal) into 1.49772.
|
||||
price: 'verbatim-string',
|
||||
currency: 'verbatim-string',
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the NuExtract user-turn text: the template (pretty-printed with the
|
||||
* indent the model cards use) followed by the document, under a `# Template:`
|
||||
* header. This is the exact inline format the GGUF model cards document.
|
||||
*/
|
||||
export function buildNuExtractUserText(documentText: string): string {
|
||||
return `# Template:\n${JSON.stringify(NUEXTRACT_TEMPLATE, null, 4)}\n${documentText}`;
|
||||
}
|
||||
|
||||
/** NuExtract `type` token → schema.org reservation `@type`. */
|
||||
const TYPE_MAP: Record<string, string> = {
|
||||
flight: 'FlightReservation',
|
||||
train: 'TrainReservation',
|
||||
bus: 'BusReservation',
|
||||
ferry: 'BoatReservation',
|
||||
boat: 'BoatReservation',
|
||||
cruise: 'BoatReservation',
|
||||
car: 'RentalCarReservation',
|
||||
hotel: 'LodgingReservation',
|
||||
lodging: 'LodgingReservation',
|
||||
restaurant: 'FoodEstablishmentReservation',
|
||||
event: 'EventReservation',
|
||||
};
|
||||
|
||||
/** Recursively drop null/undefined/blank leaves and the empty objects/arrays they leave behind. */
|
||||
function clean(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const arr = value.map(clean).filter((v) => v !== undefined);
|
||||
return arr.length ? arr : undefined;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
const c = clean(v);
|
||||
if (c !== undefined) out[k] = c;
|
||||
}
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
}
|
||||
if (value === null || value === undefined) return undefined;
|
||||
if (typeof value === 'string' && value.trim() === '') return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a localized money string into a plain number. Handles German
|
||||
* ("1.580,22 €" → 1580.22) and English ("1,580.22"/"$89.00" → 89) grouping by
|
||||
* treating the right-most separator as the decimal point. Returns null when there
|
||||
* is no parseable amount.
|
||||
*/
|
||||
function parseAmount(raw: unknown): number | null {
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null;
|
||||
if (typeof raw !== 'string') return null;
|
||||
let s = raw.replace(/[^\d.,]/g, '');
|
||||
if (!s) return null;
|
||||
const lastComma = s.lastIndexOf(',');
|
||||
const lastDot = s.lastIndexOf('.');
|
||||
let decimal: ',' | '.' | null = null;
|
||||
if (lastComma > -1 && lastDot > -1) {
|
||||
decimal = lastComma > lastDot ? ',' : '.';
|
||||
} else if (lastComma > -1) {
|
||||
// A single comma with ≤2 trailing digits is a decimal point; otherwise grouping.
|
||||
const parts = s.split(',');
|
||||
decimal = parts.length === 2 && parts[1].length <= 2 ? ',' : null;
|
||||
} else if (lastDot > -1) {
|
||||
const parts = s.split('.');
|
||||
decimal = parts.length === 2 && parts[1].length <= 2 ? '.' : null;
|
||||
}
|
||||
if (decimal) {
|
||||
const grouping = decimal === ',' ? '.' : ',';
|
||||
s = s.split(grouping).join('').replace(decimal, '.');
|
||||
} else {
|
||||
s = s.replace(/[.,]/g, '');
|
||||
}
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** Resolve an ISO 4217 currency from a symbol or code found in either field. */
|
||||
function parseCurrency(...candidates: unknown[]): string | undefined {
|
||||
for (const c of candidates) {
|
||||
if (typeof c !== 'string') continue;
|
||||
const s = c.toUpperCase();
|
||||
if (s.includes('€') || /\bEUR\b/.test(s)) return 'EUR';
|
||||
if (s.includes('£') || /\bGBP\b/.test(s)) return 'GBP';
|
||||
if (s.includes('$') || /\bUSD\b/.test(s)) return 'USD';
|
||||
const iso = s.match(/\b([A-Z]{3})\b/);
|
||||
if (iso) return iso[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A venue's display name, falling back to the address (or a generic label) so a
|
||||
* lodging/restaurant/event is never silently dropped when the model misses the name. */
|
||||
function nameOrFallback(x: Record<string, unknown>, fallback: string): string {
|
||||
const name = typeof x.name === 'string' ? x.name.trim() : '';
|
||||
if (name) return name;
|
||||
const address = typeof x.address === 'string' ? x.address.trim() : '';
|
||||
if (address) return address.split(',')[0].trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Map one flat NuExtract reservation into a schema.org `KiReservation` node (or undefined). */
|
||||
function buildNode(x: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const atType = TYPE_MAP[String(x.type ?? '').toLowerCase().trim()];
|
||||
if (!atType) return undefined;
|
||||
|
||||
const node: Record<string, unknown> = {
|
||||
'@type': atType,
|
||||
reservationNumber: x.booking_reference,
|
||||
seat: x.seat,
|
||||
class: x.travel_class,
|
||||
platform: x.platform,
|
||||
price: parseAmount(x.price) ?? undefined,
|
||||
priceCurrency: parseCurrency(x.currency, x.price),
|
||||
};
|
||||
|
||||
switch (atType) {
|
||||
case 'FlightReservation':
|
||||
node.reservationFor = {
|
||||
flightNumber: x.vehicle_number,
|
||||
airline: x.operator ? { name: x.operator } : undefined,
|
||||
departureAirport: { iataCode: x.from_code, name: x.from_name },
|
||||
arrivalAirport: { iataCode: x.to_code, name: x.to_name },
|
||||
departureTime: x.departure_time,
|
||||
arrivalTime: x.arrival_time,
|
||||
};
|
||||
break;
|
||||
case 'TrainReservation':
|
||||
node.reservationFor = {
|
||||
trainNumber: x.vehicle_number,
|
||||
departureStation: { name: x.from_name },
|
||||
arrivalStation: { name: x.to_name },
|
||||
departureTime: x.departure_time,
|
||||
arrivalTime: x.arrival_time,
|
||||
};
|
||||
break;
|
||||
case 'BusReservation':
|
||||
node.reservationFor = {
|
||||
busNumber: x.vehicle_number,
|
||||
departureBusStop: { name: x.from_name },
|
||||
arrivalBusStop: { name: x.to_name },
|
||||
departureTime: x.departure_time,
|
||||
arrivalTime: x.arrival_time,
|
||||
};
|
||||
break;
|
||||
case 'BoatReservation':
|
||||
node.reservationFor = {
|
||||
name: x.name ?? x.operator,
|
||||
departureBoatTerminal: { name: x.from_name },
|
||||
arrivalBoatTerminal: { name: x.to_name },
|
||||
departureTime: x.departure_time,
|
||||
arrivalTime: x.arrival_time,
|
||||
};
|
||||
break;
|
||||
case 'LodgingReservation':
|
||||
node.reservationFor = { name: nameOrFallback(x, 'Accommodation'), address: x.address, telephone: x.telephone, url: x.website };
|
||||
node.checkinTime = x.checkin_time;
|
||||
node.checkoutTime = x.checkout_time;
|
||||
break;
|
||||
case 'FoodEstablishmentReservation':
|
||||
node.reservationFor = { name: nameOrFallback(x, 'Restaurant'), address: x.address, telephone: x.telephone, url: x.website };
|
||||
node.startTime = x.start_time;
|
||||
node.endTime = x.end_time;
|
||||
break;
|
||||
case 'RentalCarReservation':
|
||||
// Pick-up / return ride the transport from/to fields (see template comment).
|
||||
node.reservationFor = { name: x.name, rentalCompany: x.operator ? { name: x.operator } : undefined };
|
||||
node.pickupTime = x.departure_time;
|
||||
node.dropoffTime = x.arrival_time;
|
||||
node.pickupLocation = { name: x.from_name, address: x.address };
|
||||
node.dropoffLocation = { name: x.to_name };
|
||||
break;
|
||||
case 'EventReservation':
|
||||
node.reservationFor = {
|
||||
name: nameOrFallback(x, 'Event'),
|
||||
startDate: x.start_time,
|
||||
endDate: x.end_time,
|
||||
location: { address: x.address, telephone: x.telephone, url: x.website },
|
||||
};
|
||||
node.startTime = x.start_time;
|
||||
node.endTime = x.end_time;
|
||||
break;
|
||||
}
|
||||
|
||||
return clean(node) as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a parsed NuExtract response into schema.org `KiReservation` nodes.
|
||||
* Accepts the `{ reservations: [...] }` wrapper the template asks for, a bare
|
||||
* array, or a single object. Unrecognized/empty entries are dropped.
|
||||
*/
|
||||
export function nuExtractToKiReservations(parsed: unknown): Record<string, unknown>[] {
|
||||
const wrapped = (parsed as { reservations?: unknown })?.reservations;
|
||||
const list = Array.isArray(wrapped)
|
||||
? wrapped
|
||||
: Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed && typeof parsed === 'object'
|
||||
? [parsed]
|
||||
: [];
|
||||
|
||||
const out: Record<string, unknown>[] = [];
|
||||
for (const entry of list) {
|
||||
if (entry && typeof entry === 'object') {
|
||||
const node = buildNode(entry as Record<string, unknown>);
|
||||
if (node) out.push(node);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
|
||||
import { isNuExtractModel, buildNuExtractUserText, nuExtractToKiReservations } from './nuextract';
|
||||
|
||||
// Generous: a local CPU model (Ollama, no GPU) may cold-load several GB and then
|
||||
// take a few minutes on a longer document before the first token.
|
||||
const TIMEOUT_MS = 300_000;
|
||||
const MAX_TOKENS = 4096;
|
||||
|
||||
/**
|
||||
* OpenAI-compatible chat-completions client. Covers both the "openai" cloud
|
||||
* provider and the "local" provider (Ollama / vLLM / llama.cpp / LM Studio),
|
||||
* which all expose `POST {baseUrl}/chat/completions`. Native binaries (PDF) are
|
||||
* sent as an OpenAI `file` content part; text goes as a text part. Uses the
|
||||
* global fetch (no SDK) to match the codebase's HTTP style.
|
||||
*
|
||||
* A NuExtract model (detected by id) takes a different request shape: the JSON
|
||||
* template inlined in a single user message, no system prompt and no
|
||||
* `response_format` (see ./nuextract.ts) — that's how the fine-tune expects to
|
||||
* be driven; the generic instruct path applies to every other model.
|
||||
*/
|
||||
export class OpenAiCompatibleClient implements LlmExtractionClient {
|
||||
async extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]> {
|
||||
const base = (input.baseUrl ?? 'https://api.openai.com/v1').replace(/\/+$/, '');
|
||||
const url = `${base}/chat/completions`;
|
||||
const nuextract = isNuExtractModel(input.model);
|
||||
|
||||
const userContent: unknown[] = nuextract
|
||||
? [{ type: 'text', text: buildNuExtractUserText(input.text ?? '') }]
|
||||
: [{ type: 'text', text: input.text ? `${USER_TEXT}\n\n${input.text}` : USER_TEXT }];
|
||||
// Only genuine images go natively (as image_url) — OpenAI-compatible servers
|
||||
// (notably Ollama) reject `file`/PDF content parts. PDFs reach this client as
|
||||
// pre-extracted text (see llm-parse.service.ts), never as bytes.
|
||||
if (!nuextract && input.file && input.file.mimeType.startsWith('image/')) {
|
||||
const b64 = input.file.data.toString('base64');
|
||||
userContent.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${input.file.mimeType};base64,${b64}` },
|
||||
});
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: input.model,
|
||||
max_tokens: MAX_TOKENS,
|
||||
// Extraction is a deterministic task — Ollama defaults to 0.7, which makes
|
||||
// small models (NuExtract) drop fields or return empty. Pin to 0.
|
||||
temperature: 0,
|
||||
// NuExtract wants the template (in the user turn) to be the only instruction
|
||||
// — a system prompt or a json_schema grammar derails it.
|
||||
messages: nuextract
|
||||
? [{ role: 'user', content: userContent }]
|
||||
: [
|
||||
{ role: 'system', content: input.prompt },
|
||||
{ role: 'user', content: userContent },
|
||||
],
|
||||
...(nuextract
|
||||
? {}
|
||||
: {
|
||||
response_format: {
|
||||
type: 'json_schema' as const,
|
||||
json_schema: { name: 'reservations', schema: input.jsonSchema, strict: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(input.apiKey ? { authorization: `Bearer ${input.apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '');
|
||||
throw new Error(`LLM request failed (${res.status}): ${detail.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
return nuextract ? parseNuExtract(content) : parseReservations(content);
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip code fences and JSON.parse; `null` on failure. */
|
||||
function parseJson(content: string | undefined | null): unknown {
|
||||
if (!content) return null;
|
||||
const stripped = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
|
||||
try {
|
||||
return JSON.parse(stripped);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a NuExtract response and map its flat template output to KiReservation nodes. */
|
||||
function parseNuExtract(content: string | undefined | null): Record<string, unknown>[] {
|
||||
return nuExtractToKiReservations(parseJson(content));
|
||||
}
|
||||
|
||||
const USER_TEXT = 'Extract every travel reservation from the following document as schema.org JSON-LD.';
|
||||
|
||||
/** Tolerant parse: strip code fences, JSON.parse, pull `reservations`. `[]` on failure. */
|
||||
function parseReservations(content: string | undefined | null): Record<string, unknown>[] {
|
||||
const parsed = parseJson(content);
|
||||
if (Array.isArray(parsed)) return parsed as Record<string, unknown>[];
|
||||
if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { reservations?: unknown }).reservations)) {
|
||||
return (parsed as { reservations: Record<string, unknown>[] }).reservations;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LlmExtractionClient } from './llm-provider.interface';
|
||||
import type { ResolvedLlmConfig } from '../../services/llmConfig';
|
||||
import { OpenAiCompatibleClient } from './clients/openai-compatible.client';
|
||||
import { AnthropicClient } from './clients/anthropic.client';
|
||||
|
||||
/**
|
||||
* Pick the provider client for a resolved config.
|
||||
* - 'anthropic' → Anthropic Messages API client
|
||||
* - 'openai' | 'local' → OpenAI-compatible client (cloud or local base URL)
|
||||
*/
|
||||
export function createLlmClient(config: ResolvedLlmConfig): LlmExtractionClient {
|
||||
switch (config.provider) {
|
||||
case 'anthropic':
|
||||
return new AnthropicClient();
|
||||
case 'openai':
|
||||
case 'local':
|
||||
return new OpenAiCompatibleClient();
|
||||
// TODO(nuextract): add a NuExtract template adapter here (local vision model
|
||||
// with its own template-fill API) once the OpenAI-compatible path proves
|
||||
// insufficient for small local models — see the design seam in the plan.
|
||||
default:
|
||||
return new OpenAiCompatibleClient();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { db } from '../../db/database';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { getUserSettings, getDecryptedUserSetting } from '../../services/settingsService';
|
||||
import { decryptLlmApiKey, LLM_PROVIDERS, type LlmProvider, type ResolvedLlmConfig } from '../../services/llmConfig';
|
||||
|
||||
function asProvider(v: unknown): LlmProvider | null {
|
||||
return typeof v === 'string' && (LLM_PROVIDERS as string[]).includes(v) ? (v as LlmProvider) : null;
|
||||
}
|
||||
|
||||
function readInstanceConfig(): ResolvedLlmConfig | null {
|
||||
const row = db.prepare('SELECT config FROM addons WHERE id = ?').get(ADDON_IDS.LLM_PARSING) as { config?: string } | undefined;
|
||||
if (!row?.config) return null;
|
||||
let cfg: Record<string, unknown>;
|
||||
try {
|
||||
cfg = JSON.parse(row.config || '{}');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const provider = asProvider(cfg.provider);
|
||||
const model = typeof cfg.model === 'string' ? cfg.model.trim() : '';
|
||||
if (!provider || !model) return null;
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
baseUrl: typeof cfg.baseUrl === 'string' && cfg.baseUrl.trim() ? cfg.baseUrl.trim() : undefined,
|
||||
apiKey: decryptLlmApiKey(cfg.apiKey),
|
||||
multimodal: cfg.multimodal === true,
|
||||
};
|
||||
}
|
||||
|
||||
function readUserConfig(userId: number): ResolvedLlmConfig | null {
|
||||
const settings = getUserSettings(userId);
|
||||
const provider = asProvider(settings.llm_provider);
|
||||
const model = typeof settings.llm_model === 'string' ? settings.llm_model.trim() : '';
|
||||
if (!provider || !model) return null;
|
||||
const apiKey = getDecryptedUserSetting(userId, 'llm_api_key') ?? undefined;
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
baseUrl: typeof settings.llm_base_url === 'string' && settings.llm_base_url.trim() ? settings.llm_base_url.trim() : undefined,
|
||||
apiKey,
|
||||
multimodal: settings.llm_multimodal === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective LLM config for a user, gated by the addon.
|
||||
* Order: addon disabled → null; admin instance config wins; else per-user config;
|
||||
* else null. This is the single place the API key is decrypted.
|
||||
*/
|
||||
export function resolveLlmConfig(userId: number): ResolvedLlmConfig | null {
|
||||
if (!isAddonEnabled(ADDON_IDS.LLM_PARSING)) return null;
|
||||
return readInstanceConfig() ?? readUserConfig(userId);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Controller, Get, Post, Query, Body, Res, UseGuards } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { AdminGuard } from '../auth/admin.guard';
|
||||
import { LlmLocalService } from './llm-local.service';
|
||||
|
||||
/**
|
||||
* Admin-only management of a local LLM server (Ollama): list installed models and
|
||||
* pull new ones (e.g. NuExtract). Used by the AI-parsing addon config UI.
|
||||
*/
|
||||
@Controller('api/admin/llm/local')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
export class LlmLocalController {
|
||||
constructor(private readonly local: LlmLocalService) {}
|
||||
|
||||
@Get('models')
|
||||
models(@Query('baseUrl') baseUrl?: string) {
|
||||
return this.local.listModels(baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a model pull. Proxies Ollama's NDJSON progress lines
|
||||
* ({ status, total?, completed? }) straight to the client, which reads the
|
||||
* response body to render a progress bar. Uses @Res() to stream manually.
|
||||
*/
|
||||
@Post('pull')
|
||||
async pull(@Body() body: { baseUrl?: string; model?: string }, @Res() res: Response): Promise<void> {
|
||||
const stream = await this.local.pull(body?.baseUrl, body?.model ?? '');
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', 'application/x-ndjson');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
res.write(Buffer.from(value));
|
||||
}
|
||||
} catch {
|
||||
// Upstream dropped mid-pull — close the response; the client surfaces it.
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable, HttpException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Admin helpers for managing a local OpenAI-compatible LLM server (Ollama).
|
||||
* Talks to Ollama's *management* API (`/api/tags`, `/api/pull`), which lives at
|
||||
* the server root — not the `/v1` OpenAI-compatible path the extraction client
|
||||
* uses. Admin-only (guarded at the controller); the base URL is admin-supplied
|
||||
* and typically points at a localhost Ollama, so SSRF guarding is intentionally
|
||||
* not applied (it would block localhost) — we only validate the protocol.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LlmLocalService {
|
||||
/** Derive the Ollama root from a configured base URL (strip a trailing /v1). */
|
||||
ollamaRoot(baseUrl: string | undefined): string {
|
||||
const raw = (baseUrl ?? 'http://localhost:11434').trim();
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new HttpException({ error: 'Invalid base URL' }, 400);
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new HttpException({ error: 'Base URL must be http(s)' }, 400);
|
||||
}
|
||||
return raw.replace(/\/+$/, '').replace(/\/v1$/, '');
|
||||
}
|
||||
|
||||
/** List models already pulled on the local server. */
|
||||
async listModels(baseUrl: string | undefined): Promise<{ models: { name: string; size: number }[] }> {
|
||||
const root = this.ollamaRoot(baseUrl);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${root}/api/tags`, { signal: AbortSignal.timeout(10_000) });
|
||||
} catch {
|
||||
throw new HttpException({ error: `Could not reach local LLM server at ${root}` }, 502);
|
||||
}
|
||||
if (!res.ok) throw new HttpException({ error: `Local LLM server error (${res.status})` }, 502);
|
||||
const data = (await res.json()) as { models?: { name?: string; size?: number }[] };
|
||||
const models = (data.models ?? []).map(m => ({ name: m.name ?? '', size: m.size ?? 0 })).filter(m => m.name);
|
||||
return { models };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a streamed pull. Returns the upstream NDJSON body so the controller can
|
||||
* pipe Ollama's progress lines straight to the client.
|
||||
*/
|
||||
async pull(baseUrl: string | undefined, model: string): Promise<ReadableStream<Uint8Array>> {
|
||||
if (!model?.trim()) throw new HttpException({ error: 'model is required' }, 400);
|
||||
const root = this.ollamaRoot(baseUrl);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${root}/api/pull`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: model.trim(), stream: true }),
|
||||
});
|
||||
} catch {
|
||||
throw new HttpException({ error: `Could not reach local LLM server at ${root}` }, 502);
|
||||
}
|
||||
if (!res.ok || !res.body) throw new HttpException({ error: `Pull failed (${res.status})` }, 502);
|
||||
return res.body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LlmParseService } from './llm-parse.service';
|
||||
import { LlmLocalService } from './llm-local.service';
|
||||
import { LlmLocalController } from './llm-local.controller';
|
||||
|
||||
/** Provides the LLM booking-import fallback; imported by BookingImportModule. */
|
||||
@Module({
|
||||
controllers: [LlmLocalController],
|
||||
providers: [LlmParseService, LlmLocalService],
|
||||
exports: [LlmParseService],
|
||||
})
|
||||
export class LlmParseModule {}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { KiReservation } from '../booking-import/kitinerary.types';
|
||||
import { createLlmClient } from './llm-client.factory';
|
||||
import { resolveLlmConfig } from './llm-config.resolver';
|
||||
import { buildSystemPrompt, KI_RESERVATION_JSON_SCHEMA } from './llm-prompt';
|
||||
import type { LlmExtractionInput } from './llm-provider.interface';
|
||||
import { isPdf, extractText } from './text-extract';
|
||||
import { routeExtraction } from './router/extraction-router';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { kiReservationSchema } from '@trek/shared';
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
export interface LlmParseResult {
|
||||
kiItems: KiReservation[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the LLM fallback: resolve config → pick client → build input
|
||||
* (native bytes vs extracted text by the `multimodal` flag) → call provider →
|
||||
* validate the response → return schema.org `KiReservation[]` for the shared
|
||||
* mapper. Never throws for content/provider reasons — degrades to `[]` + a
|
||||
* warning, mirroring the kitinerary extractor's tolerance.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LlmParseService {
|
||||
/** True when the addon is enabled AND a usable config resolves for this user. */
|
||||
isAvailable(userId: number): boolean {
|
||||
return resolveLlmConfig(userId) !== null;
|
||||
}
|
||||
|
||||
async parse(file: { buffer: Buffer; originalName: string }, userId: number): Promise<LlmParseResult> {
|
||||
const config = resolveLlmConfig(userId);
|
||||
if (!config) return { kiItems: [], warnings: ['AI parsing is not configured'] };
|
||||
|
||||
const warnings: string[] = [];
|
||||
const input: LlmExtractionInput = {
|
||||
prompt: buildSystemPrompt(),
|
||||
jsonSchema: KI_RESERVATION_JSON_SCHEMA,
|
||||
model: config.model,
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: config.apiKey,
|
||||
};
|
||||
|
||||
// Native PDF only for Anthropic (its document block reads text AND scans).
|
||||
// OpenAI-compatible servers (incl. Ollama/NuExtract) can't ingest PDFs/`file`
|
||||
// parts, so every other provider gets extracted text.
|
||||
try {
|
||||
if (config.provider === 'anthropic' && isPdf(file.originalName)) {
|
||||
input.file = { mimeType: MIME_BY_EXT['.pdf'], data: file.buffer };
|
||||
console.debug(
|
||||
`[DEBUG] Extracted (native PDF, ${file.buffer.length} bytes) sent to ${config.provider}: ${file.originalName}`,
|
||||
);
|
||||
} else {
|
||||
input.text = await extractText(file.buffer, file.originalName);
|
||||
// The local router decomposes the document and extracts one reservation at a
|
||||
// time, so it tolerates more text than the single-shot path (which had to cap
|
||||
// at 4000 to fit a small context). Cloud single-shot keeps the tight cap.
|
||||
const MAX_EXTRACT_CHARS = config.provider === 'local' ? 16000 : 4000;
|
||||
if (input.text.length > MAX_EXTRACT_CHARS) input.text = input.text.slice(0, MAX_EXTRACT_CHARS);
|
||||
console.debug(`[DEBUG] Extracted text from ${file.originalName} (${input.text.length} chars):\n`, input.text);
|
||||
if (!input.text.trim()) {
|
||||
return {
|
||||
kiItems: [],
|
||||
warnings: [`${file.originalName}: no readable text found (a scanned PDF needs a cloud/vision provider)`],
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
kiItems: [],
|
||||
warnings: [`${file.originalName}: could not read file — ${err instanceof Error ? err.message : String(err)}`],
|
||||
};
|
||||
}
|
||||
|
||||
// Local provider (Ollama): go through the layered extraction router — vendor
|
||||
// templates → decompose + grammar-enforced per-reservation extraction → validate
|
||||
// + repair. Far more reliable on small CPU models than the single-shot path below
|
||||
// (which stays for cloud providers, whose strong models handle one-shot well).
|
||||
if (config.provider === 'local' && input.text) {
|
||||
try {
|
||||
const routed = await routeExtraction(input.text, {
|
||||
baseUrl: config.baseUrl ?? 'http://localhost:11434/v1',
|
||||
model: config.model,
|
||||
apiKey: config.apiKey,
|
||||
});
|
||||
return { kiItems: routed.kiItems, warnings: [...warnings, ...routed.warnings] };
|
||||
} catch (err) {
|
||||
return {
|
||||
kiItems: [],
|
||||
warnings: [`${file.originalName}: AI parsing failed — ${err instanceof Error ? err.message : String(err)}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let raw: Record<string, unknown>[];
|
||||
try {
|
||||
raw = await createLlmClient(config).extract(input);
|
||||
console.debug('[DEBUG] Raw LLM Response: ', raw);
|
||||
} catch (err) {
|
||||
return {
|
||||
kiItems: [],
|
||||
warnings: [`${file.originalName}: AI parsing failed — ${err instanceof Error ? err.message : String(err)}`],
|
||||
};
|
||||
}
|
||||
|
||||
const kiItems: KiReservation[] = [];
|
||||
for (const node of raw) {
|
||||
const result = kiReservationSchema.safeParse(node);
|
||||
if (result.success) kiItems.push(normalizeNode(result.data) as unknown as KiReservation);
|
||||
else warnings.push(`${file.originalName}: skipped an unrecognized AI result`);
|
||||
}
|
||||
|
||||
return { kiItems, warnings };
|
||||
}
|
||||
}
|
||||
|
||||
/** Root-level keys in the schema.org reservation shape; everything else is trip-specific. */
|
||||
const ROOT_KEYS = new Set([
|
||||
'@type',
|
||||
'reservationNumber',
|
||||
'checkinTime',
|
||||
'checkoutTime',
|
||||
'pickupTime',
|
||||
'dropoffTime',
|
||||
'startTime',
|
||||
'endTime',
|
||||
'pickupLocation',
|
||||
'dropoffLocation',
|
||||
'seat',
|
||||
'class',
|
||||
'platform',
|
||||
'price',
|
||||
'priceCurrency',
|
||||
'reservationFor',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Small models often flatten the type-specific fields (flightNumber, airline,
|
||||
* departureAirport, …) onto the reservation root instead of nesting them under
|
||||
* `reservationFor`, which is where the kitinerary mapper reads them. When
|
||||
* `reservationFor` is missing/empty, fold the non-root keys into it so the
|
||||
* existing mappers work unchanged.
|
||||
*/
|
||||
function normalizeNode(node: Record<string, unknown>): Record<string, unknown> {
|
||||
const rf = node.reservationFor;
|
||||
if (rf && typeof rf === 'object' && Object.keys(rf as object).length > 0) return node;
|
||||
|
||||
const out: Record<string, unknown> = {};
|
||||
const reservationFor: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(node)) {
|
||||
if (ROOT_KEYS.has(k)) out[k] = v;
|
||||
else reservationFor[k] = v;
|
||||
}
|
||||
// Nothing to fold (no flattened type fields) — leave the node as-is.
|
||||
if (Object.keys(reservationFor).length === 0) return node;
|
||||
out.reservationFor = reservationFor;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { KI_RESERVATION_JSON_SCHEMA, KI_RESERVATION_TYPES } from '@trek/shared';
|
||||
|
||||
export { KI_RESERVATION_JSON_SCHEMA };
|
||||
|
||||
/**
|
||||
* System instructions telling the model to emit schema.org reservation JSON-LD
|
||||
* in exactly the shape the kitinerary binary produces — so the result feeds the
|
||||
* same `mapReservations()` mapper. Pure (no I/O) so it's unit-testable.
|
||||
*/
|
||||
export function buildSystemPrompt(): string {
|
||||
return [
|
||||
'You extract travel reservations from a document (a booking confirmation, ticket, or itinerary).',
|
||||
'Return ONLY a JSON object of the form { "reservations": [ ... ] } — no prose, no markdown.',
|
||||
'Each reservation is a schema.org JSON-LD object whose "@type" is one of:',
|
||||
KI_RESERVATION_TYPES.map((t) => ` - ${t}`).join('\n'),
|
||||
'Put the booking/confirmation code in "reservationNumber" on each reservation.',
|
||||
'All dates/times are plain ISO 8601 local strings, e.g. "2026-06-11T10:00:00" (no timezone wrapper objects).',
|
||||
'IMPORTANT: nest the type-specific fields INSIDE a "reservationFor" object — do NOT place them at the top level of the reservation.',
|
||||
'Populate "reservationFor" with the type-specific fields:',
|
||||
' FlightReservation: { flightNumber, airline:{name,iataCode}, departureAirport:{iataCode,name,geo:{latitude,longitude}}, arrivalAirport:{...}, departureTime, arrivalTime }',
|
||||
' TrainReservation: { trainNumber, trainName, departureStation:{name,geo}, arrivalStation:{name,geo}, departureTime, arrivalTime }',
|
||||
' BusReservation: { busNumber, busName, departureBusStop:{name,geo}, arrivalBusStop:{name,geo}, departureTime, arrivalTime }',
|
||||
' BoatReservation: { name, departureBoatTerminal:{name,geo}, arrivalBoatTerminal:{name,geo}, departureTime, arrivalTime }',
|
||||
' LodgingReservation: { name, address, geo:{latitude,longitude}, telephone, url } — put check-in/out in root "checkinTime"/"checkoutTime"',
|
||||
' FoodEstablishmentReservation: { name, address, geo, telephone, url } — put booking time in root "startTime"/"endTime"',
|
||||
' RentalCarReservation: { name, model, make, rentalCompany:{name} } — put pickup/dropoff times in root "pickupTime"/"dropoffTime", and the pickup AND return stations in root "pickupLocation" and "dropoffLocation", each {name,address,geo:{latitude,longitude}}',
|
||||
' EventReservation / TouristAttractionVisit: { name, startDate, endDate, location:{name,address,geo,telephone,url} }',
|
||||
'When present, also include at the reservation ROOT: "seat", "class" (fare/cabin class), "platform" (trains/buses), and the total "price" (a number) with "priceCurrency" (ISO 4217 code, e.g. EUR).',
|
||||
'Extract EVERY flight/segment in the document, including return legs — a round trip has TWO OR MORE flights, and each row of a flight table is a separate reservation. Do NOT stop after the first.',
|
||||
"Each flight shares the booking's reservationNumber. Use the date shown for that specific flight as its departureTime; if a flight lists only one date (no separate arrival time), leave arrivalTime null — never reuse another flight's date.",
|
||||
'If the document contains no recognizable reservation, return { "reservations": [] }.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Short user-turn instruction that accompanies the document content. */
|
||||
export const USER_INSTRUCTION = 'Extract every travel reservation from the following document as schema.org JSON-LD.';
|
||||
@@ -0,0 +1,30 @@
|
||||
/** A single binary file (e.g. a PDF) sent natively to a multimodal provider. */
|
||||
export interface LlmExtractionFile {
|
||||
mimeType: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
/** Everything a provider client needs to extract reservations from one document. */
|
||||
export interface LlmExtractionInput {
|
||||
/** System instructions enumerating the schema.org shape (see llm-prompt.ts). */
|
||||
prompt: string;
|
||||
/** JSON Schema describing `{ reservations: KiReservation[] }`. */
|
||||
jsonSchema: object;
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
/** Pre-extracted text (text-like files, or text-only-model mode). */
|
||||
text?: string;
|
||||
/** Native binary (PDF) for multimodal providers. */
|
||||
file?: LlmExtractionFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider client turns one document into raw schema.org reservation objects.
|
||||
* It returns the parsed `reservations` array (best-effort: `[]` on a malformed or
|
||||
* empty response, never throwing for content reasons). The caller validates and
|
||||
* maps via the shared kitinerary mapper.
|
||||
*/
|
||||
export interface LlmExtractionClient {
|
||||
extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]>;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* The extraction router (Schicht 0–2) — tuned for ONE model call per document.
|
||||
*
|
||||
* 0. deterministic vendor templates first (no LLM, instant);
|
||||
* 1. exactly one grammar-ENFORCED call (Ollama native `format`):
|
||||
* - flights → a flat ARRAY of legs in a single call (a capable model fills every
|
||||
* leg at once — far faster than one call per leg);
|
||||
* - otherwise → one flat single-reservation call, on the FAST model when the type is
|
||||
* obvious from keywords (the common case), else the strong model with a union schema;
|
||||
* 2. booking-wide fields (PNR, total price) and the overnight-arrival day are filled
|
||||
* DETERMINISTICALLY from the text — the model isn't asked to repeat or reason about them.
|
||||
*
|
||||
* No per-leg fan-out and no repair round-trips: that 4–8× call count was the latency that made
|
||||
* a multi-leg flight take minutes on a CPU host. The flat results map into the kitinerary
|
||||
* pipeline via the existing `nuExtractToKiReservations` mapper, so nothing downstream changes.
|
||||
*/
|
||||
|
||||
import type { KiReservation } from '../../booking-import/kitinerary.types';
|
||||
import { nuExtractToKiReservations } from '../clients/nuextract';
|
||||
import { FLAT_SCHEMA_BY_TYPE, FLIGHTS_ARRAY_SCHEMA, UNION_SINGLE_SCHEMA, type FlatType } from './flat-schemas';
|
||||
import { extractEnforced } from './ollama-format.client';
|
||||
import { matchVendorTemplate } from './vendor-templates';
|
||||
import type { FlatLike } from './validate';
|
||||
|
||||
export interface RouterContext {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
const TRANSPORT_TYPES: FlatType[] = ['flight', 'train', 'bus', 'ferry'];
|
||||
|
||||
/** Per-type guidance for the single-reservation prompt. */
|
||||
const TYPE_HINT: Record<FlatType, string> = {
|
||||
flight: 'flight. vehicle_number = flight number, from_code/to_code = IATA codes, times = full ISO.',
|
||||
train: 'train. from_name/to_name = stations, vehicle_number = train number, times = full ISO.',
|
||||
bus: 'bus. from_name/to_name = stops, times = full ISO.',
|
||||
ferry: 'ferry/cruise. from_name/to_name = terminals/ports, times = full ISO.',
|
||||
car: 'rental car. from_name = pick-up location, to_name = return location (may differ), departure_time = pick-up, arrival_time = return.',
|
||||
hotel: 'hotel stay. name = hotel name, checkin_time/checkout_time = full ISO date-time.',
|
||||
restaurant: 'restaurant booking. name = the restaurant, start_time = the reservation date-time.',
|
||||
event: 'event/attraction. name = the event, start_time/end_time = full ISO.',
|
||||
};
|
||||
|
||||
/** Keyword → reservation type, so an obvious document skips the costlier union/strong path. */
|
||||
const TYPE_KEYWORDS: [FlatType, RegExp][] = [
|
||||
['car', /\b(sixt|europcar|hertz|avis|enterprise|mietwagen|rental\s*car|autovermietung|anmietung|r(?:ü|ue)ckgabe|pick-?up|drop-?off)\b/i],
|
||||
['hotel', /\b(hotel|check-?in|check-?out|(?:ü|ue)bernachtung|zimmer|room\s*night|lodging|airbnb|b&b|hostel|pension)\b/i],
|
||||
['train', /\b(deutsche\s*bahn|bahn|train|railway|\bice\b|\bzug\b|gleis|sncf|trenitalia|renfe)\b/i],
|
||||
['bus', /\b(flixbus|\bbus\b|coach|omnibus)\b/i],
|
||||
['ferry', /\b(f(?:ä|ae)hre|ferry|cruise|kreuzfahrt)\b/i],
|
||||
['restaurant', /\b(restaurant|\btisch\b|table\s*for|men(?:ü|u)|gedeck)\b/i],
|
||||
['event', /\b(ticket|concert|konzert|veranstaltung|eintritt|admission)\b/i],
|
||||
];
|
||||
|
||||
function detectType(text: string): FlatType | null {
|
||||
for (const [type, re] of TYPE_KEYWORDS) if (re.test(text)) return type;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Detect flight numbers (order-preserving, deduped) — also the "is this a flight doc" test. */
|
||||
export function detectFlightNumbers(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const m of text.matchAll(/\b([A-Z]{2})\s?(\d{2,4})\b/g)) {
|
||||
const fn = `${m[1]}${m[2]}`;
|
||||
if (!out.includes(fn)) out.push(fn);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking/confirmation code, pulled once for the whole document. Covers the German
|
||||
* "Bestätigungs-Code" (Airbnb) and "Reservation No." (rental brokers) on top of the PNR /
|
||||
* Buchungsnummer / Confirmation forms. The match is left-most in the text, so a customer
|
||||
* "Reservation No." that precedes a vendor "Supplier Reference" wins.
|
||||
*/
|
||||
export function extractBookingRef(text: string): string | undefined {
|
||||
// The captured code must contain a digit: real PNRs/booking codes effectively always
|
||||
// do, while the case-insensitive [A-Z0-9] class would otherwise grab a following prose
|
||||
// word ("Confirmation\nThank you…" → "Thank") after a bare label.
|
||||
const m = text.match(
|
||||
/(?:PNR|Buchungs(?:code|nummer|referenz)|Booking\s*(?:reference|code|number)|Confirmation\s*(?:number|code)?|Reservierungsnummer|Reservation\s*(?:No\.?|Number|Nr\.?)|Best(?:ä|ae)tigungs[-\s]?(?:nummer|code)|Reference)\s*:?\s*((?=[A-Z0-9]*\d)[A-Z0-9]{5,})/i,
|
||||
);
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
/** Currency symbol/code → ISO 4217. */
|
||||
function normCurrency(s: string): string | undefined {
|
||||
const u = s.toUpperCase();
|
||||
if (u.includes('€') || u === 'EUR') return 'EUR';
|
||||
if (u.includes('$') || u === 'USD') return 'USD';
|
||||
if (u.includes('£') || u === 'GBP') return 'GBP';
|
||||
if (/^[A-Z]{3}$/.test(u)) return u;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The booking total, pulled deterministically (raw amount string + ISO currency). */
|
||||
export function extractTotalPrice(text: string): { price: string; currency?: string } | null {
|
||||
const m = text.match(
|
||||
/(?:Gesamtpreis|Gesamtbetrag|Gesamtsumme|Total(?:\s*(?:price|amount))?|Amount|Summe|Betrag)\s*:?\s*([€$£]?\s*\d[\d.,]*)\s*(EUR|USD|GBP|CHF|€|\$|£)?/i,
|
||||
);
|
||||
if (!m) return null;
|
||||
return { price: m[1].replace(/[€$£\s]/g, ''), currency: normCurrency(m[2] ?? m[1]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a transport leg's arrival DATE deterministically: same day as departure, rolled to
|
||||
* the next day only when the arrival clock time is earlier than departure (an overnight leg).
|
||||
* The model reads clock times reliably but mishandles the day rollover.
|
||||
*/
|
||||
export function fixArrivalDate(flat: FlatLike): FlatLike {
|
||||
if (!TRANSPORT_TYPES.includes(flat.type)) return flat;
|
||||
const dep = /(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(String(flat.departure_time ?? ''));
|
||||
const arr = /(\d{2}:\d{2})/.exec(String(flat.arrival_time ?? ''));
|
||||
if (!dep || !arr) return flat;
|
||||
const [, depDate, depTime] = dep;
|
||||
const arrTime = arr[1];
|
||||
const d = new Date(`${depDate}T00:00:00Z`);
|
||||
if (arrTime < depTime) d.setUTCDate(d.getUTCDate() + 1);
|
||||
flat.arrival_time = `${d.toISOString().slice(0, 10)}T${arrTime}:00`;
|
||||
return flat;
|
||||
}
|
||||
|
||||
const DATE_FIELDS = ['departure_time', 'arrival_time', 'checkin_time', 'checkout_time', 'start_time', 'end_time'] as const;
|
||||
|
||||
/**
|
||||
* Coerce a date value to ISO 8601. Models occasionally ignore the format instruction and
|
||||
* emit a natural-language date ("Aug 23 2025 13:30"), which the downstream `splitIso` then
|
||||
* slices into garbage ("Aug 23 202"). Keep already-ISO values untouched; otherwise parse and
|
||||
* reformat. (The server runs in UTC, so the components line up.)
|
||||
*/
|
||||
function toIso(value: unknown): unknown {
|
||||
if (typeof value !== 'string' || !value.trim()) return value;
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return value;
|
||||
const t = Date.parse(value);
|
||||
if (Number.isNaN(t)) return value;
|
||||
const d = new Date(t);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}T${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:00`;
|
||||
}
|
||||
|
||||
/** Normalize every date-ish field on a flat reservation to ISO before mapping. */
|
||||
function normalizeDates(flat: FlatLike): FlatLike {
|
||||
for (const f of DATE_FIELDS) if (f in flat) (flat as Record<string, unknown>)[f] = toIso((flat as Record<string, unknown>)[f]);
|
||||
return flat;
|
||||
}
|
||||
|
||||
/** One enforced call extracting every flight leg as a flat array. */
|
||||
async function extractFlights(text: string, ctx: RouterContext): Promise<FlatLike[]> {
|
||||
const system =
|
||||
'Extract EVERY flight segment in the document (each flight number is one segment; a round trip has the ' +
|
||||
'outbound AND the return legs). vehicle_number = the flight number, from_code/to_code = 3-letter IATA codes, ' +
|
||||
"departure_time/arrival_time = full ISO 'YYYY-MM-DDTHH:MM:00' using the date of the section heading each flight is listed under.";
|
||||
const out = await extractEnforced({ baseUrl: ctx.baseUrl, model: ctx.model, apiKey: ctx.apiKey, system, user: `Document:\n${text}`, schema: FLIGHTS_ARRAY_SCHEMA, numPredict: 900 });
|
||||
const legs = Array.isArray((out as { flights?: unknown })?.flights) ? (out as { flights: Record<string, unknown>[] }).flights : [];
|
||||
return legs.map((leg) => fixArrivalDate(normalizeDates({ ...leg, type: 'flight' as FlatType })));
|
||||
}
|
||||
|
||||
/** One enforced call for a single reservation — a type-specific schema when the type is
|
||||
* obvious from keywords, else a union schema the model fills with the type it picks. */
|
||||
async function extractSingle(text: string, ctx: RouterContext): Promise<FlatLike> {
|
||||
const known = detectType(text);
|
||||
const call = (schema: Record<string, unknown>, hint: string) =>
|
||||
extractEnforced({
|
||||
baseUrl: ctx.baseUrl, model: ctx.model, apiKey: ctx.apiKey,
|
||||
system: `Extract the single reservation from the document into the flat fields. ${hint} Omit any field that is truly absent.`,
|
||||
user: `Document:\n${text}`,
|
||||
schema,
|
||||
});
|
||||
|
||||
if (known) {
|
||||
const out = (await call(FLAT_SCHEMA_BY_TYPE[known], `It is a ${TYPE_HINT[known]}`)) ?? {};
|
||||
return fixArrivalDate(normalizeDates({ ...out, type: known }));
|
||||
}
|
||||
const out = (await call(UNION_SINGLE_SCHEMA, 'Pick the correct "type".')) ?? {};
|
||||
const type = (typeof out.type === 'string' ? out.type : 'hotel') as FlatType;
|
||||
return fixArrivalDate(normalizeDates({ ...out, type }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the router on extracted document text and return schema.org KiReservation nodes.
|
||||
* Returns `[]` (never throws for content reasons) so the caller degrades gracefully.
|
||||
*/
|
||||
/**
|
||||
* Schicht 2 — fill the booking-wide fields the per-reservation extraction doesn't carry:
|
||||
* the confirmation/PNR and the booking total. Applied to BOTH the deterministic vendor
|
||||
* results AND the model output, so a vendor template that read the structured fields but
|
||||
* whose narrow ref/price regex missed still gets the broad doc-wide deterministic value.
|
||||
* Never overrides a value the source already provided.
|
||||
*/
|
||||
function fillBookingWideFields(flats: Array<Record<string, unknown>>, text: string): void {
|
||||
const ref = extractBookingRef(text);
|
||||
const total = extractTotalPrice(text);
|
||||
// A small model sometimes emits an empty string for a price it didn't find, which is
|
||||
// not `null` — treat blank/whitespace as "no price" so the deterministic total still wins.
|
||||
const priceMissing = (v: unknown) => v == null || (typeof v === 'string' && v.trim() === '');
|
||||
flats.forEach((f, i) => {
|
||||
if (!f.booking_reference && ref) f.booking_reference = ref;
|
||||
// The total belongs to the booking, so attach it once (the first item).
|
||||
if (i === 0 && total && priceMissing(f.price)) {
|
||||
f.price = total.price;
|
||||
if (f.currency == null) f.currency = total.currency;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function routeExtraction(text: string, ctx: RouterContext): Promise<{ kiItems: KiReservation[]; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Schicht 0 — deterministic vendor templates (no LLM). Still top-up the booking-wide
|
||||
// fields so a template misses on the ref/price doesn't drop them when the doc-wide
|
||||
// deterministic extractor would have found them.
|
||||
const vendor = matchVendorTemplate(text);
|
||||
if (vendor && vendor.length > 0) {
|
||||
fillBookingWideFields(vendor as unknown as Array<Record<string, unknown>>, text);
|
||||
return { kiItems: nuExtractToKiReservations(vendor) as unknown as KiReservation[], warnings };
|
||||
}
|
||||
|
||||
// Schicht 1 — exactly one model call.
|
||||
let flats: FlatLike[];
|
||||
try {
|
||||
flats = detectFlightNumbers(text).length > 0 ? await extractFlights(text, ctx) : [await extractSingle(text, ctx)];
|
||||
} catch (err) {
|
||||
return { kiItems: [], warnings: [`AI parsing failed — ${err instanceof Error ? err.message : String(err)}`] };
|
||||
}
|
||||
|
||||
// Schicht 2 — deterministic booking-wide fields the per-call schema doesn't carry.
|
||||
fillBookingWideFields(flats as unknown as Array<Record<string, unknown>>, text);
|
||||
|
||||
const kiItems = nuExtractToKiReservations(flats as unknown as Record<string, unknown>[]) as unknown as KiReservation[];
|
||||
return { kiItems, warnings };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Type-specific FLAT JSON Schemas for the extraction router.
|
||||
*
|
||||
* The router drives a local model with a small, flat, single-reservation schema and
|
||||
* lets Ollama's native `format` parameter constrain sampling to it (grammar-level —
|
||||
* see ollama-format.client.ts). Two findings shape this:
|
||||
* - Enforcing the big nested `{reservations:[union of 8 types]}` schema makes small
|
||||
* local models collapse (grammar compliance falls off a cliff on deep schemas), so
|
||||
* we never enforce the monolith — only one flat object at a time.
|
||||
* - A flat schema whose key fields are `required` forces the model to actually fill
|
||||
* flightNumber / from / to / dates instead of leaving them null, which is the single
|
||||
* biggest reliability win for a small model.
|
||||
*
|
||||
* The flat field names match NUEXTRACT_TEMPLATE so the existing flat→schema.org mapper
|
||||
* (`nuExtractToKiReservations`) maps the result straight into the kitinerary pipeline.
|
||||
*/
|
||||
|
||||
export type FlatType = 'flight' | 'train' | 'bus' | 'ferry' | 'car' | 'hotel' | 'restaurant' | 'event';
|
||||
|
||||
export const FLAT_TYPES: FlatType[] = ['flight', 'train', 'bus', 'ferry', 'car', 'hotel', 'restaurant', 'event'];
|
||||
|
||||
type JsonSchema = Record<string, unknown>;
|
||||
|
||||
const STR = { type: 'string' } as const;
|
||||
|
||||
/** Build a flat object schema from a field list, marking `required` the ones enforcement must guarantee. */
|
||||
function flat(fields: string[], required: string[]): JsonSchema {
|
||||
const properties: Record<string, typeof STR> = {};
|
||||
for (const f of fields) properties[f] = STR;
|
||||
return { type: 'object', properties, required };
|
||||
}
|
||||
|
||||
/**
|
||||
* One schema per reservation type. `required` names the fields the model MUST emit;
|
||||
* everything else is optional. The router knows the type up-front (from the classifier),
|
||||
* so the type token itself is not part of the extraction schema — it's set afterwards.
|
||||
*/
|
||||
export const FLAT_SCHEMA_BY_TYPE: Record<FlatType, JsonSchema> = {
|
||||
flight: flat(
|
||||
['booking_reference', 'operator', 'vehicle_number', 'from_code', 'from_name', 'to_code', 'to_name', 'departure_time', 'arrival_time', 'seat', 'travel_class', 'price', 'currency'],
|
||||
// booking_reference (PNR) is REQUIRED: the mapper groups legs into one booking by
|
||||
// shared reservationNumber, so a missing PNR would split a round-trip into loose legs.
|
||||
// Enforcing it makes the small model actually copy it instead of leaving it null.
|
||||
['vehicle_number', 'from_code', 'to_code', 'departure_time', 'booking_reference'],
|
||||
),
|
||||
train: flat(
|
||||
['booking_reference', 'operator', 'vehicle_number', 'from_name', 'to_name', 'departure_time', 'arrival_time', 'seat', 'travel_class', 'platform', 'price', 'currency'],
|
||||
['from_name', 'to_name', 'departure_time'],
|
||||
),
|
||||
bus: flat(
|
||||
['booking_reference', 'operator', 'vehicle_number', 'from_name', 'to_name', 'departure_time', 'arrival_time', 'seat', 'price', 'currency'],
|
||||
['from_name', 'to_name', 'departure_time'],
|
||||
),
|
||||
ferry: flat(
|
||||
['booking_reference', 'operator', 'name', 'from_name', 'to_name', 'departure_time', 'arrival_time', 'price', 'currency'],
|
||||
['from_name', 'to_name', 'departure_time'],
|
||||
),
|
||||
car: flat(
|
||||
['booking_reference', 'operator', 'name', 'from_name', 'to_name', 'departure_time', 'arrival_time', 'price', 'currency'],
|
||||
['from_name', 'departure_time', 'arrival_time'],
|
||||
),
|
||||
hotel: flat(
|
||||
['name', 'booking_reference', 'address', 'checkin_time', 'checkout_time', 'telephone', 'website', 'price', 'currency'],
|
||||
['name', 'checkin_time', 'checkout_time'],
|
||||
),
|
||||
restaurant: flat(
|
||||
['name', 'booking_reference', 'address', 'start_time', 'end_time', 'telephone', 'website', 'price', 'currency'],
|
||||
['name'],
|
||||
),
|
||||
event: flat(
|
||||
['name', 'booking_reference', 'address', 'start_time', 'end_time', 'telephone', 'website', 'price', 'currency'],
|
||||
['name'],
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* All flight legs of a document in ONE shot: a flat array. A capable model (7b) fills
|
||||
* every leg reliably in a single call — far faster than one call per leg — and the
|
||||
* booking-wide fields (PNR, total price) are recovered deterministically afterwards.
|
||||
*/
|
||||
export const FLIGHTS_ARRAY_SCHEMA: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
flights: {
|
||||
type: 'array',
|
||||
items: flat(
|
||||
['vehicle_number', 'operator', 'from_code', 'from_name', 'to_code', 'to_name', 'departure_time', 'arrival_time', 'seat', 'travel_class'],
|
||||
['vehicle_number', 'from_code', 'to_code', 'departure_time'],
|
||||
),
|
||||
},
|
||||
},
|
||||
required: ['flights'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Single-reservation fallback when the document type isn't obvious from keywords:
|
||||
* one flat object the model fills, choosing the `type` itself. Used on the strong
|
||||
* model so the type pick is reliable.
|
||||
*/
|
||||
export const UNION_SINGLE_SCHEMA: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', enum: FLAT_TYPES },
|
||||
name: STR, booking_reference: STR, operator: STR, vehicle_number: STR,
|
||||
from_name: STR, from_code: STR, to_name: STR, to_code: STR,
|
||||
departure_time: STR, arrival_time: STR, address: STR,
|
||||
checkin_time: STR, checkout_time: STR, start_time: STR, end_time: STR,
|
||||
telephone: STR, website: STR, price: STR, currency: STR,
|
||||
},
|
||||
required: ['type'],
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Minimal Ollama native-API client used by the extraction router.
|
||||
*
|
||||
* Why not the OpenAI-compatible `/v1/chat/completions` path the rest of llm-parse uses?
|
||||
* Ollama's `/v1` endpoint does NOT faithfully honour OpenAI's `response_format:{json_schema,strict}`
|
||||
* (it's passed through loosely — the schema and `strict` flag are effectively ignored).
|
||||
* Ollama's OWN `/api/chat` endpoint with a top-level `format: <jsonSchema>` is the path that
|
||||
* actually compiles the schema to a GBNF grammar and constrains token sampling. That hard
|
||||
* guarantee — valid, type-correct, all-required-fields JSON — is the router's foundation,
|
||||
* so the router talks to `/api/chat` directly. (Cloud providers enforce via their own strict
|
||||
* tool/response_format and keep using the existing clients.)
|
||||
*/
|
||||
|
||||
const TIMEOUT_MS = 300_000;
|
||||
|
||||
export interface EnforcedExtractInput {
|
||||
/** Ollama base URL — accepts the addon's `…/v1` form; the `/v1` suffix is stripped. */
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
system: string;
|
||||
user: string;
|
||||
/** JSON Schema the output is constrained to (grammar-level). */
|
||||
schema: Record<string, unknown>;
|
||||
apiKey?: string;
|
||||
numPredict?: number;
|
||||
/** Context window. 8192 fits a typical multi-section booking; raise for long itineraries. */
|
||||
numCtx?: number;
|
||||
}
|
||||
|
||||
/** Resolve the native API base from a config base URL that may end in `/v1`. */
|
||||
export function toNativeBase(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '');
|
||||
}
|
||||
|
||||
/** Strip code fences and JSON.parse; returns null on failure. */
|
||||
function parseJson(content: string | undefined | null): unknown {
|
||||
if (!content) return null;
|
||||
const stripped = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
|
||||
try {
|
||||
return JSON.parse(stripped);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one schema-constrained chat completion against Ollama's native `/api/chat`.
|
||||
* Returns the parsed JSON object (constrained to `schema`), or null if the request
|
||||
* failed or produced unparseable output.
|
||||
*/
|
||||
export async function extractEnforced(input: EnforcedExtractInput): Promise<Record<string, unknown> | null> {
|
||||
const url = `${toNativeBase(input.baseUrl)}/api/chat`;
|
||||
const body = {
|
||||
model: input.model,
|
||||
stream: false,
|
||||
format: input.schema,
|
||||
// Keep the model resident a while so back-to-back imports don't pay the cold load.
|
||||
keep_alive: '30m',
|
||||
options: { temperature: 0, num_predict: input.numPredict ?? 512, num_ctx: input.numCtx ?? 8192 },
|
||||
messages: [
|
||||
{ role: 'system', content: input.system },
|
||||
{ role: 'user', content: input.user },
|
||||
],
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(input.apiKey ? { authorization: `Bearer ${input.apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '');
|
||||
throw new Error(`Ollama /api/chat failed (${res.status}): ${detail.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { message?: { content?: string } };
|
||||
const parsed = parseJson(data.message?.content);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Schicht 2 — semantic validation of an extracted flat reservation.
|
||||
*
|
||||
* Constrained decoding guarantees the JSON is structurally valid, but NOT that the
|
||||
* values make sense. This layer catches the failure modes that actually hurt users —
|
||||
* a date with no day, a check-out before check-in, a bogus IATA code, a missing
|
||||
* booking reference — and returns a human-readable problem list. The router feeds that
|
||||
* list back to the model for ONE targeted repair pass; whatever still fails is left for
|
||||
* the human (the review-before-save modal, Schicht 3) rather than silently dropped.
|
||||
*/
|
||||
|
||||
import { findByIata } from '../../../services/airportService';
|
||||
import type { FlatType } from './flat-schemas';
|
||||
|
||||
/** A value that contains a full calendar date (YYYY-MM-DD), not just a time. */
|
||||
function hasFullDate(v: unknown): boolean {
|
||||
return typeof v === 'string' && /\d{4}-\d{2}-\d{2}/.test(v);
|
||||
}
|
||||
|
||||
/** The YYYY-MM-DD portion, or null. */
|
||||
function datePart(v: unknown): string | null {
|
||||
if (typeof v !== 'string') return null;
|
||||
const m = v.match(/\d{4}-\d{2}-\d{2}/);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
function looksLikeIata(v: unknown): boolean {
|
||||
return typeof v === 'string' && /^[A-Za-z]{3}$/.test(v.trim());
|
||||
}
|
||||
|
||||
export interface FlatLike {
|
||||
type: FlatType;
|
||||
booking_reference?: string;
|
||||
vehicle_number?: string;
|
||||
from_code?: string;
|
||||
to_code?: string;
|
||||
from_name?: string;
|
||||
to_name?: string;
|
||||
departure_time?: string;
|
||||
arrival_time?: string;
|
||||
checkin_time?: string;
|
||||
checkout_time?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
const TRANSPORT: FlatType[] = ['flight', 'train', 'bus', 'ferry'];
|
||||
|
||||
/**
|
||||
* Return a list of human-readable problems with a flat reservation, suitable for a
|
||||
* repair prompt. An empty list means it passed. `requireReference` adds a check for a
|
||||
* missing booking code (bookings almost always carry one — a miss usually means the
|
||||
* model skipped it, not that it's absent).
|
||||
*/
|
||||
export function validateFlat(flat: FlatLike, requireReference = true): string[] {
|
||||
const problems: string[] = [];
|
||||
const t = flat.type;
|
||||
|
||||
if (requireReference && !str(flat.booking_reference)) {
|
||||
problems.push('the booking/confirmation reference is missing — copy it from the document');
|
||||
}
|
||||
|
||||
if (TRANSPORT.includes(t)) {
|
||||
if (!str(flat.from_code) && !str(flat.from_name)) problems.push('missing departure location');
|
||||
if (!str(flat.to_code) && !str(flat.to_name)) problems.push('missing arrival location');
|
||||
if (!hasFullDate(flat.departure_time)) {
|
||||
problems.push("departure_time must be a full date-time (YYYY-MM-DDTHH:MM:00) using THIS segment's date");
|
||||
}
|
||||
if (t === 'flight') {
|
||||
if (!str(flat.vehicle_number)) problems.push('missing flight number');
|
||||
for (const [label, code] of [['departure', flat.from_code], ['arrival', flat.to_code]] as const) {
|
||||
if (str(code) && !looksLikeIata(code)) problems.push(`${label} airport code "${String(code)}" is not a 3-letter IATA code`);
|
||||
else if (looksLikeIata(code) && !findByIata(String(code).toUpperCase())) {
|
||||
problems.push(`${label} airport code "${String(code).toUpperCase()}" is not a known IATA code — re-check it`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasFullDate(flat.departure_time) && hasFullDate(flat.arrival_time)) {
|
||||
if (new Date(flat.arrival_time as string) < new Date(flat.departure_time as string)) {
|
||||
problems.push('arrival_time is before departure_time — re-read the times');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (t === 'hotel') {
|
||||
if (!hasFullDate(flat.checkin_time)) problems.push('checkin_time must be a full date');
|
||||
if (!hasFullDate(flat.checkout_time)) problems.push('checkout_time must be a full date');
|
||||
const ci = datePart(flat.checkin_time);
|
||||
const co = datePart(flat.checkout_time);
|
||||
if (ci && co && co < ci) problems.push('check-out date is before check-in — re-read both dates');
|
||||
}
|
||||
|
||||
if (t === 'car') {
|
||||
if (!hasFullDate(flat.departure_time)) problems.push('the pickup date-time (departure_time) must be a full date');
|
||||
if (!hasFullDate(flat.arrival_time)) problems.push('the return date-time (arrival_time) must be a full date');
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
|
||||
function str(v: unknown): boolean {
|
||||
return typeof v === 'string' && v.trim().length > 0;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Schicht 0 — deterministic vendor templates.
|
||||
*
|
||||
* KItinerary already handles documents with machine-readable data (boarding-pass
|
||||
* barcodes, UIC rail codes, embedded schema.org JSON-LD) upstream of the LLM. This
|
||||
* layer extends the deterministic net to a handful of high-volume vendors whose plain
|
||||
* PDFs carry NO barcode but a stable text layout (Booking.com, Expedia, Airbnb, the big
|
||||
* airlines, Sixt/Europcar…). A matched template returns a fully-formed result with ZERO
|
||||
* model inference — instant, free, and 100% repeatable — so the common case never loads
|
||||
* the CPU. The LLM router only runs for the long tail.
|
||||
*
|
||||
* Templates emit the same flat field shape the router uses, so they feed the identical
|
||||
* `nuExtractToKiReservations` mapper. Each template must be CONSERVATIVE: fire only on an
|
||||
* unambiguous marker and only emit fields it can read with certainty — a wrong
|
||||
* deterministic answer is worse than deferring to the model. This file is the seam where
|
||||
* new vendor extractors are added; it ships with one worked example.
|
||||
*/
|
||||
|
||||
import type { FlatType } from './flat-schemas';
|
||||
|
||||
export interface FlatReservation {
|
||||
type: FlatType;
|
||||
booking_reference?: string;
|
||||
operator?: string;
|
||||
name?: string;
|
||||
from_name?: string;
|
||||
to_name?: string;
|
||||
departure_time?: string;
|
||||
arrival_time?: string;
|
||||
address?: string;
|
||||
checkin_time?: string;
|
||||
checkout_time?: string;
|
||||
price?: string;
|
||||
currency?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface VendorTemplate {
|
||||
name: string;
|
||||
/** Cheap check: is this that vendor's document at all? */
|
||||
match(text: string): boolean;
|
||||
/** Pull the reservation(s); return [] if the layout didn't parse as expected. */
|
||||
extract(text: string): FlatReservation[];
|
||||
}
|
||||
|
||||
/** Parse a German/EU numeric date + time ("24.12.2026, 10:00" / "24.12.2026 10:00 Uhr") to ISO. */
|
||||
function deDateTime(text: string): string | null {
|
||||
const m = text.match(/(\d{2})\.(\d{2})\.(\d{4})(?:[,\s]+(\d{1,2}):(\d{2}))?/);
|
||||
if (!m) return null;
|
||||
const [, d, mo, y, h, mi] = m;
|
||||
return `${y}-${mo}-${d}` + (h ? `T${h.padStart(2, '0')}:${mi}:00` : '');
|
||||
}
|
||||
|
||||
/** German month name/abbreviation → month number (matched on the first three letters). */
|
||||
const DE_MONTHS: Record<string, number> = {
|
||||
jan: 1, feb: 2, 'mär': 3, mrz: 3, apr: 4, mai: 5, jun: 6, jul: 7, aug: 8, sep: 9, okt: 10, nov: 11, dez: 12,
|
||||
};
|
||||
/** English month name/abbreviation → month number (matched on the first three letters). */
|
||||
const EN_MONTHS: Record<string, number> = {
|
||||
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
|
||||
};
|
||||
|
||||
/** Parse a German long-form date ("3. Mai 2026", "27. Aug. 2025") to an ISO date — no time. */
|
||||
function deLongDate(text: string): string | null {
|
||||
const m = text.match(/(\d{1,2})\.\s*([A-Za-zäöüÄÖÜ]+)\.?\s+(\d{4})/);
|
||||
if (!m) return null;
|
||||
const mo = DE_MONTHS[m[2].slice(0, 3).toLowerCase()];
|
||||
if (!mo) return null;
|
||||
return `${m[3]}-${String(mo).padStart(2, '0')}-${m[1].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an English date + optional time to ISO. Tolerates a comma after the day
|
||||
* ("Aug 5, 2025") and a 12-hour clock ("Aug 23 2025 01:30 PM" → 13:30) as well as the
|
||||
* plain 24-hour form ("Aug 23 2025 13:30", "Aug 30 2025").
|
||||
*/
|
||||
function enDateTime(text: string): string | null {
|
||||
const m = text.match(/([A-Za-z]{3,})\.?\s+(\d{1,2}),?\s+(\d{4})(?:[,\s]+(\d{1,2}):(\d{2})\s*([AaPp][Mm])?)?/);
|
||||
if (!m) return null;
|
||||
const mo = EN_MONTHS[m[1].slice(0, 3).toLowerCase()];
|
||||
if (!mo) return null;
|
||||
const date = `${m[3]}-${String(mo).padStart(2, '0')}-${m[2].padStart(2, '0')}`;
|
||||
if (!m[4]) return date;
|
||||
let h = parseInt(m[4], 10);
|
||||
const meridiem = m[6]?.toLowerCase();
|
||||
if (meridiem === 'pm' && h !== 12) h += 12;
|
||||
else if (meridiem === 'am' && h === 12) h = 0;
|
||||
return `${date}T${String(h).padStart(2, '0')}:${m[5]}:00`;
|
||||
}
|
||||
|
||||
/** Symbol/code → ISO 4217 (defaults to EUR for the EU-centric broker vouchers). */
|
||||
function moneyCurrency(token: string | undefined): string {
|
||||
if (!token) return 'EUR';
|
||||
const u = token.toUpperCase();
|
||||
if (u.includes('€')) return 'EUR';
|
||||
if (u.includes('$')) return 'USD';
|
||||
if (u.includes('£')) return 'GBP';
|
||||
return /^[A-Z]{3}$/.test(u) ? u : 'EUR';
|
||||
}
|
||||
|
||||
/**
|
||||
* Example: Sixt rental confirmation. Sixt print-PDFs carry no barcode but a stable
|
||||
* "Reservierungsnummer" + Anmietung/Rückgabe block. Conservative: only fires on the Sixt
|
||||
* marker, only emits fields it can read unambiguously, and bails to the LLM otherwise.
|
||||
*/
|
||||
const sixt: VendorTemplate = {
|
||||
name: 'sixt-rental',
|
||||
match: (t) => /\bSIXT\b/i.test(t) && /Reservierungsnummer/i.test(t),
|
||||
extract: (t) => {
|
||||
const ref = t.match(/Reservierungsnummer:?\s*([A-Z0-9]{6,})/i)?.[1];
|
||||
const pickup = t.match(/Anmietung:?\s*(.+)/i)?.[1]?.trim();
|
||||
const dropoff = t.match(/R(?:ü|ue)ckgabe:?\s*(.+)/i)?.[1]?.trim();
|
||||
const pickupTime = pickup ? deDateTime(t.slice(t.indexOf(pickup))) : null;
|
||||
const dropoffTime = dropoff ? deDateTime(t.slice(t.indexOf(dropoff))) : null;
|
||||
// Need at least a reference and both endpoints with dates to trust the template.
|
||||
if (!ref || !pickup || !dropoff || !pickupTime || !dropoffTime) return [];
|
||||
const place = (s: string) => s.replace(/\s*[-–]\s*\d{2}\.\d{2}\.\d{4}.*$/, '').trim();
|
||||
const priceM = t.match(/Gesamtpreis:?\s*([\d.,]+)\s*(EUR|€)/i);
|
||||
return [
|
||||
{
|
||||
type: 'car',
|
||||
operator: 'SIXT',
|
||||
booking_reference: ref,
|
||||
from_name: place(pickup),
|
||||
to_name: place(dropoff),
|
||||
departure_time: pickupTime,
|
||||
arrival_time: dropoffTime,
|
||||
...(priceM ? { price: priceM[1], currency: 'EUR' } : {}),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Expedia receipt ("Beleg"). Expedia's German confirmation PDFs carry no barcode but a
|
||||
* stable "Buchungsdetails" block — hotel name, address, Anreise/Abreise — and an
|
||||
* "Expedia-Reiseplan" number + "Gesamtpreis". The text layer reads these cleanly even
|
||||
* when the local model misses the address/price, so pull the hotel deterministically.
|
||||
* (A combined hotel+flight receipt only yields the hotel here — the airline lines carry
|
||||
* no IATA flight number, which the model can't reliably turn into legs either.)
|
||||
*/
|
||||
const expedia: VendorTemplate = {
|
||||
name: 'expedia-hotel',
|
||||
match: (t) => /Expedia-Reiseplan/i.test(t) && /Buchungsdetails/i.test(t) && /Anreise/i.test(t),
|
||||
extract: (t) => {
|
||||
const ref = t.match(/Expedia-Reiseplan:?\s*(\d{6,})/i)?.[1];
|
||||
const block = t.match(/Buchungsdetails\s*\n([\s\S]*?)\nAnreise:/i)?.[1];
|
||||
const checkin = deLongDate(t.match(/Anreise:?\s*([^\n]+)/i)?.[1] ?? '');
|
||||
const checkout = deLongDate(t.match(/Abreise:?\s*([^\n]+)/i)?.[1] ?? '');
|
||||
if (!block || !checkin || !checkout) return [];
|
||||
const lines = block.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
const name = lines[0];
|
||||
if (!name) return [];
|
||||
const address = lines.slice(1).join(', ') || undefined;
|
||||
const priceM = t.match(/Gesamtpreis\s*([\d.,]+)\s*€/i);
|
||||
return [
|
||||
{
|
||||
type: 'hotel',
|
||||
name,
|
||||
...(ref ? { booking_reference: ref } : {}),
|
||||
...(address ? { address } : {}),
|
||||
checkin_time: checkin,
|
||||
checkout_time: checkout,
|
||||
...(priceM ? { price: priceM[1], currency: 'EUR' } : {}),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Broker rental-car voucher (vipcars and the like). These print a stable
|
||||
* "PICK-UP DETAILS / DROP-OFF DETAILS" pair — each followed by the depot name and an
|
||||
* English "Mon DD YYYY HH:MM" line — plus a "Reservation No." and a "Payment Details"
|
||||
* total. The model regularly fails the two-column English date, so read it here.
|
||||
*/
|
||||
const brokerRental: VendorTemplate = {
|
||||
name: 'broker-rental-voucher',
|
||||
match: (t) => /PICK-?UP DETAILS/i.test(t) && /DROP-?OFF DETAILS/i.test(t) && /Reservation\s*No/i.test(t),
|
||||
extract: (t) => {
|
||||
const ref = t.match(/Reservation\s*No\.?:?\s*([A-Z0-9]{5,})/i)?.[1];
|
||||
const block = (label: RegExp) =>
|
||||
t.match(new RegExp(label.source + String.raw`\s*\n([^\n]+)\n([A-Za-z]{3,}\.?\s+\d{1,2},?\s+\d{4}[^\n]*)`, 'i'));
|
||||
const pu = block(/PICK-?UP DETAILS/);
|
||||
const dof = block(/DROP-?OFF DETAILS/);
|
||||
const puTime = pu ? enDateTime(pu[2]) : null;
|
||||
const doTime = dof ? enDateTime(dof[2]) : null;
|
||||
if (!ref || !pu || !dof || !puTime || !doTime) return [];
|
||||
const company = t
|
||||
.match(/SUPPLIER DETAILS\s*\n([^\n]+?)(?:\s+Supplier Reference|\n|$)/i)?.[1]
|
||||
?.trim()
|
||||
.replace(/\s*\(V\d+\)\s*$/i, ''); // drop the broker's "(V2)" supplier-version tag
|
||||
// Read the first amount in the "Payment Details" block; accept the currency on either
|
||||
// side of the number and derive it (don't assume EUR), so non-EUR vouchers still get a price.
|
||||
const priceM = t.match(
|
||||
/Payment Details[\s\S]{0,120}?(?:(EUR|USD|GBP|CHF|€|\$|£)\s*([\d.,]+)|([\d.,]+)\s*(EUR|USD|GBP|CHF|€|\$|£))/i,
|
||||
);
|
||||
const price = priceM ? priceM[2] ?? priceM[3] : undefined;
|
||||
return [
|
||||
{
|
||||
type: 'car',
|
||||
...(company ? { operator: company } : {}),
|
||||
booking_reference: ref,
|
||||
from_name: pu[1].trim(),
|
||||
to_name: dof[1].trim(),
|
||||
departure_time: puTime,
|
||||
arrival_time: doTime,
|
||||
...(price ? { price, currency: moneyCurrency(priceM![1] ?? priceM![4]) } : {}),
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const TEMPLATES: VendorTemplate[] = [sixt, expedia, brokerRental];
|
||||
|
||||
/**
|
||||
* Try each vendor template; return the first match's result, or null when no template
|
||||
* applies (the router then falls through to the LLM). A template that matches its vendor
|
||||
* but can't parse the layout returns [] and is skipped.
|
||||
*/
|
||||
export function matchVendorTemplate(text: string): FlatReservation[] | null {
|
||||
for (const t of TEMPLATES) {
|
||||
if (!t.match(text)) continue;
|
||||
const result = t.extract(text);
|
||||
if (result.length > 0) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { extname } from 'node:path';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
/** File extensions whose bytes are inherently text and can be decoded directly. */
|
||||
const TEXT_LIKE = new Set(['.txt', '.html', '.htm', '.eml']);
|
||||
|
||||
export function isTextLike(fileName: string): boolean {
|
||||
return TEXT_LIKE.has(extname(fileName).toLowerCase());
|
||||
}
|
||||
|
||||
export function isPdf(fileName: string): boolean {
|
||||
return extname(fileName).toLowerCase() === '.pdf';
|
||||
}
|
||||
|
||||
/** Strip HTML/XML tags and collapse whitespace for a cleaner LLM prompt. */
|
||||
function stripMarkup(s: string): string {
|
||||
return s
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Extract the embedded text layer from a PDF (empty for scanned/image-only PDFs). */
|
||||
async function extractPdfText(buffer: Buffer): Promise<string> {
|
||||
const parser = new PDFParse({ data: new Uint8Array(buffer) });
|
||||
try {
|
||||
// Space (not tab) between same-line items reads more naturally for the LLM.
|
||||
const res = await parser.getText({ cellSeparator: ' ' });
|
||||
return cleanPdfText(res.text ?? '');
|
||||
} finally {
|
||||
await parser.destroy?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up pdf-parse output for the LLM:
|
||||
* - strip `-- N of M --` page markers
|
||||
* - normalize whitespace/tabs
|
||||
* - collapse letter-spaced UPPERCASE runs ("A M S T E R D A M" → "AMSTERDAM"),
|
||||
* a common PDF kerning artifact that otherwise hides booking fields
|
||||
*/
|
||||
function cleanPdfText(text: string): string {
|
||||
return text
|
||||
.replace(/^\s*-+\s*\d+\s+of\s+\d+\s*-+\s*$/gim, '')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\b(?:[A-Z] ){2,}[A-Z]\b/g, m => m.replace(/ /g, ''))
|
||||
.replace(/ *\n */g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from a booking file for the OpenAI-compatible/local LLM path
|
||||
* (Ollama can't ingest PDFs or `file` parts, so everything becomes text).
|
||||
* - txt/html/htm/eml → decoded (markup stripped)
|
||||
* - pdf → embedded text layer via pdf-parse
|
||||
* - anything else → best-effort UTF-8 decode
|
||||
* A scanned/image-only PDF yields empty text — that case needs a vision provider
|
||||
* (Anthropic reads PDFs natively).
|
||||
*/
|
||||
export async function extractText(buffer: Buffer, fileName: string): Promise<string> {
|
||||
const ext = extname(fileName).toLowerCase();
|
||||
if (isPdf(fileName)) return extractPdfText(buffer);
|
||||
const raw = buffer.toString('utf8');
|
||||
if (ext === '.html' || ext === '.htm' || ext === '.eml') return stripMarkup(raw);
|
||||
return raw.trim();
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
|
||||
import { deleteUserCompletely } from './userCleanupService';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from './llmConfig';
|
||||
import { send as sendNotification } from './notificationService';
|
||||
import { resolveAuthToggles } from './authService';
|
||||
|
||||
@@ -670,7 +672,13 @@ export function listAddons() {
|
||||
}
|
||||
|
||||
return [
|
||||
...addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })),
|
||||
...addons.map(a => ({
|
||||
...a,
|
||||
enabled: !!a.enabled,
|
||||
config: a.id === ADDON_IDS.LLM_PARSING
|
||||
? maskLlmAddonConfig(JSON.parse(a.config || '{}'))
|
||||
: JSON.parse(a.config || '{}'),
|
||||
})),
|
||||
...providers.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -702,7 +710,14 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco
|
||||
|
||||
if (addon) {
|
||||
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
|
||||
if (data.config !== undefined) {
|
||||
// The AI-parsing addon holds an API key — encrypt it at rest and preserve
|
||||
// the stored key when the client echoes the mask sentinel (see llmConfig.ts).
|
||||
const configToStore = id === ADDON_IDS.LLM_PARSING
|
||||
? prepareLlmAddonConfigForWrite(data.config, JSON.parse(addon.config || '{}'))
|
||||
: data.config;
|
||||
db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(configToStore), id);
|
||||
}
|
||||
} else {
|
||||
if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||
}
|
||||
@@ -710,7 +725,13 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco
|
||||
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
||||
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
|
||||
const updated = updatedAddon
|
||||
? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') }
|
||||
? {
|
||||
...updatedAddon,
|
||||
enabled: !!updatedAddon.enabled,
|
||||
config: updatedAddon.id === ADDON_IDS.LLM_PARSING
|
||||
? maskLlmAddonConfig(JSON.parse(updatedAddon.config || '{}'))
|
||||
: JSON.parse(updatedAddon.config || '{}'),
|
||||
}
|
||||
: updatedProvider
|
||||
? {
|
||||
id: updatedProvider.id,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
/**
|
||||
* Shared types + helpers for the `llm_parsing` addon configuration.
|
||||
*
|
||||
* Config can live in two places (resolution happens in
|
||||
* server/src/nest/llm-parse/llm-config.resolver.ts):
|
||||
* - instance-wide: the `llm_parsing` addon's `config` JSON (admin-set, wins)
|
||||
* - per-user: the `llm_*` keys in the per-user settings table (fallback)
|
||||
*
|
||||
* The API key is encrypted at rest (reusing apiKeyCrypto) and never returned to
|
||||
* the client in plaintext — it is masked with MASKED_VALUE, matching the
|
||||
* per-user encrypted-settings pattern in settingsService.ts.
|
||||
*/
|
||||
|
||||
export type LlmProvider = 'local' | 'openai' | 'anthropic';
|
||||
|
||||
/** Fully-resolved config the clients consume. */
|
||||
export interface ResolvedLlmConfig {
|
||||
provider: LlmProvider;
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
multimodal: boolean;
|
||||
}
|
||||
|
||||
/** Shape of the admin instance config stored in `addons.config` (apiKey encrypted). */
|
||||
export interface LlmAddonConfig {
|
||||
provider?: LlmProvider;
|
||||
model?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
multimodal?: boolean;
|
||||
}
|
||||
|
||||
export const LLM_PROVIDERS: LlmProvider[] = ['local', 'openai', 'anthropic'];
|
||||
export const MASKED_VALUE = '••••••••';
|
||||
|
||||
/**
|
||||
* Prepare an admin config blob for persistence: encrypt a freshly-entered apiKey,
|
||||
* and preserve the previously-stored (already-encrypted) key when the client
|
||||
* echoes back the mask sentinel (i.e. the user didn't change it).
|
||||
*/
|
||||
export function prepareLlmAddonConfigForWrite(
|
||||
incoming: Record<string, unknown>,
|
||||
existingStored: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...incoming };
|
||||
const key = incoming.apiKey;
|
||||
if (key === undefined || key === null || key === '' || key === MASKED_VALUE) {
|
||||
// Keep the existing encrypted key untouched (mask echoed or no key supplied).
|
||||
if (existingStored && 'apiKey' in existingStored) out.apiKey = existingStored.apiKey;
|
||||
else delete out.apiKey;
|
||||
} else {
|
||||
out.apiKey = maybe_encrypt_api_key(String(key)) ?? String(key);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Mask the apiKey for any client-facing response (never leak plaintext). */
|
||||
export function maskLlmAddonConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||||
if (config && config.apiKey) return { ...config, apiKey: MASKED_VALUE };
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Decrypt the stored apiKey for server-side use (resolver only). */
|
||||
export function decryptLlmApiKey(stored: unknown): string | undefined {
|
||||
if (!stored) return undefined;
|
||||
return decrypt_api_key(stored) ?? undefined;
|
||||
}
|
||||
@@ -53,10 +53,16 @@ function resolveDayIdFromTime(
|
||||
if (!time) return null;
|
||||
const datePart = time.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
|
||||
const row = db
|
||||
const exact = db
|
||||
.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1')
|
||||
.get(tripId, datePart) as { id: number } | undefined;
|
||||
return row?.id ?? null;
|
||||
if (exact) return exact.id;
|
||||
// Fallback: clamp to the nearest day in the trip so a booking whose exact date
|
||||
// has no day row (or sits just outside the span) still lands on a day.
|
||||
const nearest = db
|
||||
.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1')
|
||||
.get(tripId, datePart) as { id: number } | undefined;
|
||||
return nearest?.id ?? null;
|
||||
}
|
||||
|
||||
function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
|
||||
@@ -71,9 +77,15 @@ function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void
|
||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
eps.forEach((e, i) => {
|
||||
insert.run(rid, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
||||
});
|
||||
// lat/lng are NOT NULL: an imported transport whose pick-up/return (or station/
|
||||
// stop) couldn't be geocoded reaches here with null coords. Skip those rows rather
|
||||
// than let the INSERT throw and fail the entire booking save — the dates still live
|
||||
// on reservation_time/reservation_end_time, so the booking lands on its day either way.
|
||||
eps
|
||||
.filter((e) => e.lat != null && e.lng != null)
|
||||
.forEach((e, i) => {
|
||||
insert.run(rid, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
||||
});
|
||||
});
|
||||
tx(reservationId, endpoints);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
|
||||
|
||||
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'mapbox_access_token']);
|
||||
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'mapbox_access_token', 'llm_api_key']);
|
||||
// Encrypted keys that are masked (••••••••) when returned to the client.
|
||||
// Keys not in this set but in ENCRYPTED_SETTING_KEYS are decrypted and returned.
|
||||
const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);
|
||||
const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'llm_api_key']);
|
||||
|
||||
export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'temperature_unit',
|
||||
@@ -22,6 +22,13 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'mapbox_style',
|
||||
'mapbox_3d_enabled',
|
||||
'mapbox_quality_mode',
|
||||
// Per-user LLM fallback config for booking import (used when the admin has not
|
||||
// set instance-wide config on the llm_parsing addon). See llmConfig.ts.
|
||||
'llm_provider',
|
||||
'llm_model',
|
||||
'llm_base_url',
|
||||
'llm_multimodal',
|
||||
'llm_api_key',
|
||||
] as const;
|
||||
|
||||
type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number];
|
||||
@@ -31,9 +38,10 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
|
||||
time_format: ['12h', '24h'],
|
||||
dark_mode: [true, false, 'light', 'dark', 'auto'],
|
||||
map_provider: ['leaflet', 'mapbox-gl'],
|
||||
llm_provider: ['local', 'openai', 'anthropic'],
|
||||
};
|
||||
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode']);
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode', 'llm_multimodal']);
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
try { return JSON.parse(raw); } catch { return raw; }
|
||||
@@ -154,3 +162,21 @@ export function bulkUpsertSettings(userId: number, settings: Record<string, unkn
|
||||
}
|
||||
return Object.keys(settings).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single per-user setting, decrypting it if it's an encrypted key.
|
||||
* Unlike getUserSettings (which MASKS encrypted keys for the client), this
|
||||
* returns the plaintext — for server-side use only (e.g. the LLM config
|
||||
* resolver needs the real API key). Returns null when unset.
|
||||
*/
|
||||
export function getDecryptedUserSetting(userId: number, key: string): string | null {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE user_id = ? AND key = ?').get(userId, key) as { value: string } | undefined;
|
||||
if (!row || row.value === '' || row.value == null) return null;
|
||||
if (ENCRYPTED_SETTING_KEYS.has(key)) return decrypt_api_key(row.value);
|
||||
try {
|
||||
const parsed = JSON.parse(row.value);
|
||||
return typeof parsed === 'string' ? parsed : row.value;
|
||||
} catch {
|
||||
return row.value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { BookingImportController } from '../../../../src/nest/booking-import/booking-import.controller';
|
||||
import type { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service';
|
||||
import type { User } from '../../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user' } as User;
|
||||
const file = (name = 'a.pdf') => ({ originalname: name, buffer: Buffer.from('x') } as Express.Multer.File);
|
||||
|
||||
function make(over: Partial<BookingImportService> = {}) {
|
||||
const svc = {
|
||||
verifyTripAccess: vi.fn(() => ({ user_id: 1 })),
|
||||
canEdit: vi.fn(() => true),
|
||||
isAvailable: vi.fn(() => true),
|
||||
aiAvailable: vi.fn(() => true),
|
||||
preview: vi.fn(async () => ({ items: [], warnings: [], files: [] })),
|
||||
...over,
|
||||
} as unknown as BookingImportService;
|
||||
return { c: new BookingImportController(svc), svc };
|
||||
}
|
||||
|
||||
async function status(fn: () => Promise<unknown>): Promise<number> {
|
||||
try { await fn(); } catch (e) { expect(e).toBeInstanceOf(HttpException); return (e as HttpException).getStatus(); }
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('BookingImportController.preview', () => {
|
||||
it('rejects an invalid mode with 400', async () => {
|
||||
const { c } = make();
|
||||
expect(await status(() => c.preview(user, 't1', [file()], 'bogus'))).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 409 for force-ai when AI is not configured', async () => {
|
||||
const { c } = make({ aiAvailable: vi.fn(() => false) as any });
|
||||
expect(await status(() => c.preview(user, 't1', [file()], 'force-ai'))).toBe(409);
|
||||
});
|
||||
|
||||
it('returns 503 for no-ai when the extractor is unavailable', async () => {
|
||||
const { c } = make({ isAvailable: vi.fn(() => false) as any });
|
||||
expect(await status(() => c.preview(user, 't1', [file()], 'no-ai'))).toBe(503);
|
||||
});
|
||||
|
||||
it('returns 400 when no files are uploaded', async () => {
|
||||
const { c } = make();
|
||||
expect(await status(() => c.preview(user, 't1', [], 'no-ai'))).toBe(400);
|
||||
});
|
||||
|
||||
it('passes the parsed mode and user id through to the service', async () => {
|
||||
const { c, svc } = make();
|
||||
await c.preview(user, 't1', [file()], 'fallback-on-empty');
|
||||
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'fallback-on-empty', 1);
|
||||
});
|
||||
|
||||
it('defaults the mode to no-ai when omitted', async () => {
|
||||
const { c, svc } = make();
|
||||
await c.preview(user, 't1', [file()], undefined);
|
||||
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'no-ai', 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
// Mock the heavy side-effect imports so the service module loads cleanly; the
|
||||
// preview() path under test only touches the extractor + llmParse deps.
|
||||
vi.mock('../../../../src/db/database', () => ({ db: { prepare: vi.fn() }, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../../src/services/permissions', () => ({ checkPermission: vi.fn(() => true) }));
|
||||
vi.mock('../../../../src/services/tripAccess', () => ({ verifyTripAccess: vi.fn() }));
|
||||
vi.mock('../../../../src/services/reservationService', () => ({ createReservation: vi.fn() }));
|
||||
vi.mock('../../../../src/services/placeService', () => ({ createPlace: vi.fn() }));
|
||||
vi.mock('../../../../src/services/mapsService', () => ({ searchNominatim: vi.fn() }));
|
||||
|
||||
import { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service';
|
||||
|
||||
const HOTEL_KI = { '@type': 'LodgingReservation', reservationNumber: 'ABC', reservationFor: { name: 'Hotel X' }, checkinTime: '2026-06-11T15:00', checkoutTime: '2026-06-12T11:00' };
|
||||
const file = (name = 'a.pdf') => ({ buffer: Buffer.from('x'), originalname: name } as any);
|
||||
|
||||
function make(opts: { kit?: boolean; ai?: boolean; extract?: any; parse?: any }) {
|
||||
const extractor = { isAvailable: () => opts.kit ?? false, extract: vi.fn(opts.extract ?? (async () => [])) };
|
||||
const llmParse = { isAvailable: () => opts.ai ?? false, parse: vi.fn(opts.parse ?? (async () => ({ kiItems: [], warnings: [] }))) };
|
||||
return { svc: new BookingImportService(extractor as any, llmParse as any), extractor, llmParse };
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('BookingImportService.preview', () => {
|
||||
it('no-ai: maps kitinerary items, does not force needs_review, reports aiUsed:false', async () => {
|
||||
const { svc, llmParse } = make({ kit: true, ai: false, extract: async () => [HOTEL_KI] });
|
||||
const res = await svc.preview([file()], 'no-ai', 1);
|
||||
expect(res.items).toHaveLength(1);
|
||||
expect(res.items[0].needs_review).toBeFalsy();
|
||||
expect(res.files).toEqual([{ fileName: 'a.pdf', aiAvailable: false, aiUsed: false }]);
|
||||
expect(llmParse.parse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws 503 when neither parser is available', async () => {
|
||||
const { svc } = make({ kit: false, ai: false });
|
||||
try {
|
||||
await svc.preview([file()], 'no-ai', 1);
|
||||
throw new Error('expected throw');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
expect((err as HttpException).getStatus()).toBe(503);
|
||||
}
|
||||
});
|
||||
|
||||
it('fallback-on-empty: runs the LLM when kitinerary finds nothing and flags needs_review', async () => {
|
||||
const { svc, extractor, llmParse } = make({
|
||||
kit: true, ai: true,
|
||||
extract: async () => [],
|
||||
parse: async () => ({ kiItems: [HOTEL_KI], warnings: [] }),
|
||||
});
|
||||
const res = await svc.preview([file()], 'fallback-on-empty', 1);
|
||||
expect(extractor.extract).toHaveBeenCalled();
|
||||
expect(llmParse.parse).toHaveBeenCalled();
|
||||
expect(res.items).toHaveLength(1);
|
||||
expect(res.items[0].needs_review).toBe(true);
|
||||
expect(res.files![0]).toEqual({ fileName: 'a.pdf', aiAvailable: true, aiUsed: true });
|
||||
});
|
||||
|
||||
it('fallback-on-empty: skips the LLM when kitinerary already found items', async () => {
|
||||
const { svc, llmParse } = make({ kit: true, ai: true, extract: async () => [HOTEL_KI] });
|
||||
const res = await svc.preview([file()], 'fallback-on-empty', 1);
|
||||
expect(llmParse.parse).not.toHaveBeenCalled();
|
||||
expect(res.files![0].aiUsed).toBe(false);
|
||||
});
|
||||
|
||||
it('force-ai: skips kitinerary entirely and uses the LLM', async () => {
|
||||
const { svc, extractor, llmParse } = make({
|
||||
kit: true, ai: true,
|
||||
parse: async () => ({ kiItems: [HOTEL_KI], warnings: [] }),
|
||||
});
|
||||
const res = await svc.preview([file()], 'force-ai', 1);
|
||||
expect(extractor.extract).not.toHaveBeenCalled();
|
||||
expect(llmParse.parse).toHaveBeenCalled();
|
||||
expect(res.items[0].needs_review).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { OpenAiCompatibleClient } from '../../../../src/nest/llm-parse/clients/openai-compatible.client';
|
||||
import { AnthropicClient } from '../../../../src/nest/llm-parse/clients/anthropic.client';
|
||||
import type { LlmExtractionInput } from '../../../../src/nest/llm-parse/llm-provider.interface';
|
||||
|
||||
const baseInput: LlmExtractionInput = {
|
||||
prompt: 'system',
|
||||
jsonSchema: { type: 'object' },
|
||||
model: 'm',
|
||||
text: 'Flight AB123',
|
||||
};
|
||||
|
||||
function mockFetch(impl: (url: string, init: RequestInit) => Promise<Response> | Response) {
|
||||
const fn = vi.fn(impl as any);
|
||||
vi.stubGlobal('fetch', fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return { ok, status, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response;
|
||||
}
|
||||
|
||||
beforeEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('OpenAiCompatibleClient', () => {
|
||||
it('posts to {baseUrl}/chat/completions and returns the reservations array', async () => {
|
||||
const fetchFn = mockFetch(() =>
|
||||
jsonResponse({ choices: [{ message: { content: JSON.stringify({ reservations: [{ '@type': 'FlightReservation' }] }) } }] }),
|
||||
);
|
||||
const out = await new OpenAiCompatibleClient().extract({ ...baseInput, baseUrl: 'http://localhost:11434/v1/' });
|
||||
expect(out).toEqual([{ '@type': 'FlightReservation' }]);
|
||||
expect(fetchFn.mock.calls[0][0]).toBe('http://localhost:11434/v1/chat/completions');
|
||||
});
|
||||
|
||||
it('tolerates code-fenced JSON', async () => {
|
||||
mockFetch(() =>
|
||||
jsonResponse({ choices: [{ message: { content: '```json\n{"reservations":[{"@type":"TrainReservation"}]}\n```' } }] }),
|
||||
);
|
||||
const out = await new OpenAiCompatibleClient().extract(baseInput);
|
||||
expect(out).toEqual([{ '@type': 'TrainReservation' }]);
|
||||
});
|
||||
|
||||
it('returns [] on malformed content', async () => {
|
||||
mockFetch(() => jsonResponse({ choices: [{ message: { content: 'not json' } }] }));
|
||||
expect(await new OpenAiCompatibleClient().extract(baseInput)).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws on non-2xx', async () => {
|
||||
mockFetch(() => jsonResponse({ error: 'bad' }, false, 401));
|
||||
await expect(new OpenAiCompatibleClient().extract(baseInput)).rejects.toThrow(/401/);
|
||||
});
|
||||
|
||||
it('sends an image natively as image_url but never a file/pdf part', async () => {
|
||||
const fetchFn = mockFetch(() => jsonResponse({ choices: [{ message: { content: '{"reservations":[]}' } }] }));
|
||||
await new OpenAiCompatibleClient().extract({ ...baseInput, file: { mimeType: 'image/png', data: Buffer.from('IMG') } });
|
||||
let parts = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string).messages[1].content;
|
||||
expect(parts.some((p: any) => p.type === 'image_url')).toBe(true);
|
||||
expect(parts.some((p: any) => p.type === 'file')).toBe(false);
|
||||
|
||||
// A PDF must NOT be sent as a content part (Ollama rejects it).
|
||||
await new OpenAiCompatibleClient().extract({ ...baseInput, file: { mimeType: 'application/pdf', data: Buffer.from('PDF') } });
|
||||
parts = JSON.parse((fetchFn.mock.calls[1][1] as RequestInit).body as string).messages[1].content;
|
||||
expect(parts.every((p: any) => p.type !== 'file' && p.type !== 'image_url')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAiCompatibleClient — NuExtract path', () => {
|
||||
it('inlines the template in one user message (no system, no response_format) and maps the flat result', async () => {
|
||||
const fetchFn = mockFetch(() =>
|
||||
jsonResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
reservations: [
|
||||
{ type: 'hotel', name: 'B&B Hotel', booking_reference: '733', checkin_time: '2026-05-01T15:00:00', checkout_time: '2026-05-02T12:00:00' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const out = await new OpenAiCompatibleClient().extract({ ...baseInput, model: 'hf.co/numind/NuExtract-2.0-2B-GGUF:latest', text: 'Hotel doc' });
|
||||
|
||||
expect(out).toEqual([
|
||||
{
|
||||
'@type': 'LodgingReservation',
|
||||
reservationNumber: '733',
|
||||
reservationFor: { name: 'B&B Hotel' },
|
||||
checkinTime: '2026-05-01T15:00:00',
|
||||
checkoutTime: '2026-05-02T12:00:00',
|
||||
},
|
||||
]);
|
||||
|
||||
const body = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.messages).toHaveLength(1);
|
||||
expect(body.messages[0].role).toBe('user');
|
||||
expect(body.messages[0].content[0].text.startsWith('# Template:')).toBe(true);
|
||||
expect(body.messages[0].content[0].text.endsWith('Hotel doc')).toBe(true);
|
||||
expect(body.temperature).toBe(0);
|
||||
expect(body.response_format).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the system prompt and response_format for non-NuExtract models', async () => {
|
||||
const fetchFn = mockFetch(() => jsonResponse({ choices: [{ message: { content: '{"reservations":[]}' } }] }));
|
||||
await new OpenAiCompatibleClient().extract({ ...baseInput, model: 'qwen2.5:7b' });
|
||||
const body = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.messages[0].role).toBe('system');
|
||||
expect(body.response_format).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnthropicClient', () => {
|
||||
it('forces the emit_reservations tool and reads its input', async () => {
|
||||
const fetchFn = mockFetch(() =>
|
||||
jsonResponse({ stop_reason: 'tool_use', content: [{ type: 'tool_use', name: 'emit_reservations', input: { reservations: [{ '@type': 'LodgingReservation' }] } }] }),
|
||||
);
|
||||
const out = await new AnthropicClient().extract(baseInput);
|
||||
expect(out).toEqual([{ '@type': 'LodgingReservation' }]);
|
||||
const body = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.tool_choice).toEqual({ type: 'tool', name: 'emit_reservations' });
|
||||
expect(body.tools[0].name).toBe('emit_reservations');
|
||||
});
|
||||
|
||||
it('throws on a refusal stop_reason', async () => {
|
||||
mockFetch(() => jsonResponse({ stop_reason: 'refusal', content: [] }));
|
||||
await expect(new AnthropicClient().extract(baseInput)).rejects.toThrow(/declined/i);
|
||||
});
|
||||
|
||||
it('throws on non-2xx', async () => {
|
||||
mockFetch(() => jsonResponse({ error: 'bad' }, false, 500));
|
||||
await expect(new AnthropicClient().extract(baseInput)).rejects.toThrow(/500/);
|
||||
});
|
||||
|
||||
it('sends a native pdf as a base64 document block', async () => {
|
||||
const fetchFn = mockFetch(() => jsonResponse({ content: [{ type: 'tool_use', name: 'emit_reservations', input: { reservations: [] } }] }));
|
||||
await new AnthropicClient().extract({ ...baseInput, file: { mimeType: 'application/pdf', data: Buffer.from('PDF') } });
|
||||
const body = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string);
|
||||
const blocks = body.messages[0].content;
|
||||
expect(blocks.some((b: any) => b.type === 'document' && b.source.type === 'base64')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn() }));
|
||||
vi.mock('../../../../src/services/adminService', () => ({ isAddonEnabled }));
|
||||
|
||||
const { getUserSettings, getDecryptedUserSetting } = vi.hoisted(() => ({
|
||||
getUserSettings: vi.fn(() => ({}) as Record<string, unknown>),
|
||||
getDecryptedUserSetting: vi.fn(() => null as string | null),
|
||||
}));
|
||||
vi.mock('../../../../src/services/settingsService', () => ({ getUserSettings, getDecryptedUserSetting }));
|
||||
|
||||
import { resolveLlmConfig } from '../../../../src/nest/llm-parse/llm-config.resolver';
|
||||
|
||||
function setInstanceConfig(config: unknown) {
|
||||
dbMock._stmt.get.mockReturnValue(config === undefined ? undefined : { config: JSON.stringify(config) });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isAddonEnabled.mockReturnValue(true);
|
||||
setInstanceConfig(undefined);
|
||||
getUserSettings.mockReturnValue({});
|
||||
getDecryptedUserSetting.mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe('resolveLlmConfig', () => {
|
||||
it('returns null when the addon is disabled', () => {
|
||||
isAddonEnabled.mockReturnValue(false);
|
||||
expect(resolveLlmConfig(1)).toBeNull();
|
||||
});
|
||||
|
||||
it('uses instance config when present (and decrypts the key)', () => {
|
||||
setInstanceConfig({ provider: 'anthropic', model: 'claude-opus-4-8', apiKey: 'sk-plain', multimodal: true });
|
||||
expect(resolveLlmConfig(1)).toEqual({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-opus-4-8',
|
||||
baseUrl: undefined,
|
||||
apiKey: 'sk-plain',
|
||||
multimodal: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to per-user config when instance config is incomplete', () => {
|
||||
setInstanceConfig({ provider: 'anthropic' }); // no model → not usable
|
||||
getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://x/v1', llm_multimodal: true });
|
||||
getDecryptedUserSetting.mockReturnValue('user-key');
|
||||
expect(resolveLlmConfig(7)).toEqual({
|
||||
provider: 'local',
|
||||
model: 'nuextract',
|
||||
baseUrl: 'http://x/v1',
|
||||
apiKey: 'user-key',
|
||||
multimodal: true,
|
||||
});
|
||||
expect(getDecryptedUserSetting).toHaveBeenCalledWith(7, 'llm_api_key');
|
||||
});
|
||||
|
||||
it('returns null when neither instance nor user config is usable', () => {
|
||||
getUserSettings.mockReturnValue({ llm_provider: 'openai' }); // no model
|
||||
expect(resolveLlmConfig(1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { LlmLocalService } from '../../../../src/nest/llm-parse/llm-local.service';
|
||||
|
||||
const svc = () => new LlmLocalService();
|
||||
|
||||
function mockFetch(impl: any) {
|
||||
const fn = vi.fn(impl);
|
||||
vi.stubGlobal('fetch', fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
beforeEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('LlmLocalService.ollamaRoot', () => {
|
||||
it('strips a trailing /v1 and slashes', () => {
|
||||
expect(svc().ollamaRoot('http://localhost:11434/v1')).toBe('http://localhost:11434');
|
||||
expect(svc().ollamaRoot('http://localhost:11434/v1/')).toBe('http://localhost:11434');
|
||||
expect(svc().ollamaRoot('http://host:1/')).toBe('http://host:1');
|
||||
});
|
||||
|
||||
it('defaults when no base URL is given', () => {
|
||||
expect(svc().ollamaRoot(undefined)).toBe('http://localhost:11434');
|
||||
});
|
||||
|
||||
it('rejects non-http(s) and invalid URLs', () => {
|
||||
expect(() => svc().ollamaRoot('ftp://x')).toThrow(HttpException);
|
||||
expect(() => svc().ollamaRoot('not a url')).toThrow(HttpException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LlmLocalService.listModels', () => {
|
||||
it('returns named models from /api/tags', async () => {
|
||||
const fetchFn = mockFetch(async () => ({ ok: true, json: async () => ({ models: [{ name: 'nuextract', size: 100 }, { name: '' }] }) }));
|
||||
const out = await svc().listModels('http://localhost:11434/v1');
|
||||
expect(out.models).toEqual([{ name: 'nuextract', size: 100 }]);
|
||||
expect(fetchFn.mock.calls[0][0]).toBe('http://localhost:11434/api/tags');
|
||||
});
|
||||
|
||||
it('502s when the server is unreachable', async () => {
|
||||
mockFetch(async () => { throw new Error('ECONNREFUSED'); });
|
||||
await expect(svc().listModels('http://localhost:11434')).rejects.toThrow(HttpException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LlmLocalService.pull', () => {
|
||||
it('requires a model', async () => {
|
||||
await expect(svc().pull('http://localhost:11434', '')).rejects.toThrow(HttpException);
|
||||
});
|
||||
|
||||
it('posts to /api/pull and returns the stream body', async () => {
|
||||
const body = {} as ReadableStream<Uint8Array>;
|
||||
const fetchFn = mockFetch(async () => ({ ok: true, body }));
|
||||
const out = await svc().pull('http://localhost:11434/v1', 'nuextract');
|
||||
expect(out).toBe(body);
|
||||
expect(fetchFn.mock.calls[0][0]).toBe('http://localhost:11434/api/pull');
|
||||
const init = fetchFn.mock.calls[0][1];
|
||||
expect(JSON.parse(init.body)).toEqual({ model: 'nuextract', stream: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { resolveLlmConfig } = vi.hoisted(() => ({ resolveLlmConfig: vi.fn() }));
|
||||
vi.mock('../../../../src/nest/llm-parse/llm-config.resolver', () => ({ resolveLlmConfig }));
|
||||
|
||||
const { createLlmClient, extract } = vi.hoisted(() => {
|
||||
const extract = vi.fn();
|
||||
return { createLlmClient: vi.fn(() => ({ extract })), extract };
|
||||
});
|
||||
vi.mock('../../../../src/nest/llm-parse/llm-client.factory', () => ({ createLlmClient }));
|
||||
|
||||
const { extractText } = vi.hoisted(() => ({ extractText: vi.fn(async () => 'Flight AB123') }));
|
||||
vi.mock('../../../../src/nest/llm-parse/text-extract', async (orig) => {
|
||||
const actual = await orig() as Record<string, unknown>;
|
||||
return { ...actual, extractText };
|
||||
});
|
||||
|
||||
import { LlmParseService } from '../../../../src/nest/llm-parse/llm-parse.service';
|
||||
|
||||
const cfg = (over: Record<string, unknown> = {}) => ({ provider: 'openai', model: 'm', multimodal: false, ...over });
|
||||
const svc = () => new LlmParseService();
|
||||
const file = (name: string, body = 'Flight AB123') => ({ buffer: Buffer.from(body), originalName: name });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveLlmConfig.mockReturnValue(cfg());
|
||||
extract.mockResolvedValue([{ '@type': 'FlightReservation' }]);
|
||||
extractText.mockResolvedValue('Flight AB123');
|
||||
});
|
||||
|
||||
describe('LlmParseService', () => {
|
||||
it('isAvailable reflects whether a config resolves', () => {
|
||||
resolveLlmConfig.mockReturnValueOnce(null);
|
||||
expect(svc().isAvailable(1)).toBe(false);
|
||||
expect(svc().isAvailable(1)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a not-configured warning when no config resolves', async () => {
|
||||
resolveLlmConfig.mockReturnValue(null);
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
expect(res.kiItems).toEqual([]);
|
||||
expect(res.warnings[0]).toMatch(/not configured/i);
|
||||
expect(extract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends extracted text for a text-like file', async () => {
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
expect(res.kiItems).toEqual([{ '@type': 'FlightReservation' }]);
|
||||
const input = extract.mock.calls[0][0];
|
||||
expect(input.text).toBe('Flight AB123');
|
||||
expect(input.file).toBeUndefined();
|
||||
});
|
||||
|
||||
it('extracts text for a pdf on the OpenAI-compatible/local path (no native bytes)', async () => {
|
||||
extractText.mockResolvedValue('Hotel X');
|
||||
await svc().parse(file('a.pdf', '%PDF'), 1);
|
||||
const input = extract.mock.calls[0][0];
|
||||
expect(input.text).toBe('Hotel X');
|
||||
expect(input.file).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends a pdf as native bytes only for Anthropic', async () => {
|
||||
resolveLlmConfig.mockReturnValue(cfg({ provider: 'anthropic' }));
|
||||
await svc().parse(file('a.pdf', '%PDF'), 1);
|
||||
const input = extract.mock.calls[0][0];
|
||||
expect(input.file).toEqual({ mimeType: 'application/pdf', data: expect.any(Buffer) });
|
||||
expect(input.text).toBeUndefined();
|
||||
expect(extractText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when a pdf yields no readable text (e.g. a scan)', async () => {
|
||||
extractText.mockResolvedValue(' ');
|
||||
const res = await svc().parse(file('a.pdf', '%PDF'), 1);
|
||||
expect(res.kiItems).toEqual([]);
|
||||
expect(res.warnings[0]).toMatch(/no readable text/i);
|
||||
expect(extract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('folds flattened type fields into reservationFor (small-model output)', async () => {
|
||||
extract.mockResolvedValue([{
|
||||
'@type': 'FlightReservation',
|
||||
reservationNumber: 'ABC',
|
||||
flightNumber: 'EZY1357',
|
||||
airline: { iataCode: 'EG' },
|
||||
departureAirport: { iataCode: 'GEG' },
|
||||
arrivalAirport: { iataCode: 'AMS' },
|
||||
departureTime: '2026-06-11T10:00:00',
|
||||
}]);
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
const item = res.kiItems[0] as any;
|
||||
expect(item.reservationNumber).toBe('ABC');
|
||||
expect(item.reservationFor).toMatchObject({ flightNumber: 'EZY1357', departureAirport: { iataCode: 'GEG' } });
|
||||
// root-level keys are not duplicated into reservationFor
|
||||
expect(item.reservationFor.reservationNumber).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves already-nested reservationFor untouched', async () => {
|
||||
extract.mockResolvedValue([{ '@type': 'FlightReservation', reservationFor: { flightNumber: 'X1' } }]);
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
expect((res.kiItems[0] as any).reservationFor).toEqual({ flightNumber: 'X1' });
|
||||
});
|
||||
|
||||
it('drops nodes without a string @type and warns', async () => {
|
||||
extract.mockResolvedValue([{ '@type': 'FlightReservation' }, { foo: 'bar' }]);
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
expect(res.kiItems).toEqual([{ '@type': 'FlightReservation' }]);
|
||||
expect(res.warnings.some(w => /unrecognized/i.test(w))).toBe(true);
|
||||
});
|
||||
|
||||
it('degrades to a warning when the client throws', async () => {
|
||||
extract.mockRejectedValue(new Error('boom'));
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
expect(res.kiItems).toEqual([]);
|
||||
expect(res.warnings[0]).toMatch(/AI parsing failed/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSystemPrompt, KI_RESERVATION_JSON_SCHEMA } from '../../../../src/nest/llm-parse/llm-prompt';
|
||||
import { KI_RESERVATION_TYPES } from '@trek/shared';
|
||||
|
||||
describe('llm-prompt', () => {
|
||||
it('names every recognized @type the mapper supports', () => {
|
||||
const prompt = buildSystemPrompt();
|
||||
for (const t of KI_RESERVATION_TYPES) expect(prompt).toContain(t);
|
||||
});
|
||||
|
||||
it('instructs JSON-only output wrapped in reservations', () => {
|
||||
const prompt = buildSystemPrompt();
|
||||
expect(prompt).toMatch(/"reservations"/);
|
||||
expect(prompt.toLowerCase()).toContain('iso 8601');
|
||||
});
|
||||
|
||||
it('exposes a strict-safe object-root JSON schema enumerating the types', () => {
|
||||
const schema = KI_RESERVATION_JSON_SCHEMA as any;
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.additionalProperties).toBe(false);
|
||||
expect(schema.required).toContain('reservations');
|
||||
const item = schema.properties.reservations.items;
|
||||
expect(item.properties['@type'].enum).toEqual([...KI_RESERVATION_TYPES]);
|
||||
expect(item.required).toContain('@type');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isNuExtractModel,
|
||||
buildNuExtractUserText,
|
||||
nuExtractToKiReservations,
|
||||
NUEXTRACT_TEMPLATE,
|
||||
} from '../../../../src/nest/llm-parse/clients/nuextract';
|
||||
|
||||
describe('isNuExtractModel', () => {
|
||||
it('matches NuExtract ids case-insensitively', () => {
|
||||
expect(isNuExtractModel('hf.co/numind/NuExtract-2.0-2B-GGUF:latest')).toBe(true);
|
||||
expect(isNuExtractModel('hf.co/numind/NuExtract3-GGUF:Q4_K_M')).toBe(true);
|
||||
expect(isNuExtractModel('nuextract')).toBe(true);
|
||||
});
|
||||
it('does not match generic instruct models', () => {
|
||||
expect(isNuExtractModel('qwen2.5:7b')).toBe(false);
|
||||
expect(isNuExtractModel('gpt-4o')).toBe(false);
|
||||
expect(isNuExtractModel(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNuExtractUserText', () => {
|
||||
it('inlines the template under a "# Template:" header followed by the document', () => {
|
||||
const text = buildNuExtractUserText('Hotel confirmation 123');
|
||||
expect(text.startsWith('# Template:\n')).toBe(true);
|
||||
expect(text).toContain('"verbatim-string"');
|
||||
expect(text).toContain(JSON.stringify(NUEXTRACT_TEMPLATE, null, 4));
|
||||
expect(text.endsWith('Hotel confirmation 123')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nuExtractToKiReservations', () => {
|
||||
it('maps a flat flight into a schema.org FlightReservation with from/to airports', () => {
|
||||
const out = nuExtractToKiReservations({
|
||||
reservations: [
|
||||
{
|
||||
type: 'flight',
|
||||
name: 'LH 198',
|
||||
booking_reference: '7XK2QP',
|
||||
operator: 'Lufthansa',
|
||||
vehicle_number: 'LH198',
|
||||
from_name: 'Berlin Brandenburg (BER)',
|
||||
from_code: 'BER',
|
||||
to_name: 'Frankfurt am Main (FRA)',
|
||||
to_code: 'FRA',
|
||||
departure_time: '2026-07-12T08:35:00',
|
||||
arrival_time: '2026-07-12T09:50:00',
|
||||
pickup_location: null,
|
||||
seat: '14A',
|
||||
travel_class: 'Economy',
|
||||
platform: null,
|
||||
price: 149,
|
||||
currency: 'EUR',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out).toEqual([
|
||||
{
|
||||
'@type': 'FlightReservation',
|
||||
reservationNumber: '7XK2QP',
|
||||
seat: '14A',
|
||||
class: 'Economy',
|
||||
price: 149,
|
||||
priceCurrency: 'EUR',
|
||||
reservationFor: {
|
||||
flightNumber: 'LH198',
|
||||
airline: { name: 'Lufthansa' },
|
||||
departureAirport: { iataCode: 'BER', name: 'Berlin Brandenburg (BER)' },
|
||||
arrivalAirport: { iataCode: 'FRA', name: 'Frankfurt am Main (FRA)' },
|
||||
departureTime: '2026-07-12T08:35:00',
|
||||
arrivalTime: '2026-07-12T09:50:00',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps a hotel with check-in/out at the reservation root', () => {
|
||||
const [node] = nuExtractToKiReservations({
|
||||
reservations: [
|
||||
{
|
||||
type: 'hotel',
|
||||
name: 'B&B Hotel Berlin-Airport',
|
||||
booking_reference: '73365505188894',
|
||||
address: 'Bertolt-Brecht-Allee 12, 12529 Schoenefeld',
|
||||
checkin_time: '2026-05-01T15:00:00',
|
||||
checkout_time: '2026-05-02T12:00:00',
|
||||
from_name: null,
|
||||
price: 89,
|
||||
currency: 'EUR',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(node).toEqual({
|
||||
'@type': 'LodgingReservation',
|
||||
reservationNumber: '73365505188894',
|
||||
price: 89,
|
||||
priceCurrency: 'EUR',
|
||||
reservationFor: { name: 'B&B Hotel Berlin-Airport', address: 'Bertolt-Brecht-Allee 12, 12529 Schoenefeld' },
|
||||
checkinTime: '2026-05-01T15:00:00',
|
||||
checkoutTime: '2026-05-02T12:00:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a rental car — pickup/return ride the from/to fields, money is parsed', () => {
|
||||
const [node] = nuExtractToKiReservations([
|
||||
{
|
||||
type: 'car',
|
||||
name: 'VW Golf',
|
||||
operator: 'SICILY BY CAR',
|
||||
booking_reference: 'CAR1',
|
||||
from_name: 'Catania Airport',
|
||||
to_name: 'Palermo Airport',
|
||||
departure_time: '2026-12-24T10:00:00',
|
||||
arrival_time: '2026-12-29T10:00:00',
|
||||
address: 'Via Roma 1',
|
||||
price: '€215,50',
|
||||
currency: '€',
|
||||
},
|
||||
]);
|
||||
expect(node).toEqual({
|
||||
'@type': 'RentalCarReservation',
|
||||
reservationNumber: 'CAR1',
|
||||
price: 215.5,
|
||||
priceCurrency: 'EUR',
|
||||
reservationFor: { name: 'VW Golf', rentalCompany: { name: 'SICILY BY CAR' } },
|
||||
pickupTime: '2026-12-24T10:00:00',
|
||||
dropoffTime: '2026-12-29T10:00:00',
|
||||
pickupLocation: { name: 'Catania Airport', address: 'Via Roma 1' },
|
||||
dropoffLocation: { name: 'Palermo Airport' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses localized money strings and currency symbols', () => {
|
||||
const [de] = nuExtractToKiReservations({ type: 'hotel', name: 'X', price: '1.580,22 €' });
|
||||
expect(de.price).toBe(1580.22);
|
||||
expect(de.priceCurrency).toBe('EUR');
|
||||
const [en] = nuExtractToKiReservations({ type: 'hotel', name: 'Y', price: '$1,580.22' });
|
||||
expect(en.price).toBe(1580.22);
|
||||
expect(en.priceCurrency).toBe('USD');
|
||||
const [plain] = nuExtractToKiReservations({ type: 'hotel', name: 'Z', price: 'EUR 89,00' });
|
||||
expect(plain.price).toBe(89);
|
||||
expect(plain.priceCurrency).toBe('EUR');
|
||||
});
|
||||
|
||||
it('falls back to the address instead of dropping a nameless lodging', () => {
|
||||
const [node] = nuExtractToKiReservations({
|
||||
type: 'hotel',
|
||||
booking_reference: 'HMHJ9RTEEK',
|
||||
address: "Via Aldo Moro, 47 n. 15, Quarto d'Altino",
|
||||
});
|
||||
expect(node['@type']).toBe('LodgingReservation');
|
||||
expect((node.reservationFor as Record<string, unknown>).name).toBe('Via Aldo Moro');
|
||||
});
|
||||
|
||||
it('accepts a bare object and drops unknown types', () => {
|
||||
expect(nuExtractToKiReservations({ type: 'flight', from_name: 'A', to_name: 'B' })).toEqual([
|
||||
{
|
||||
'@type': 'FlightReservation',
|
||||
reservationFor: {
|
||||
departureAirport: { name: 'A' },
|
||||
arrivalAirport: { name: 'B' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(nuExtractToKiReservations({ reservations: [{ type: 'spaceship' }] })).toEqual([]);
|
||||
expect(nuExtractToKiReservations(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
const { getText } = vi.hoisted(() => ({ getText: vi.fn(async () => ({ text: 'Hotel X — confirmation ABC' })) }));
|
||||
vi.mock('pdf-parse', () => ({
|
||||
PDFParse: class {
|
||||
getText = getText;
|
||||
destroy = vi.fn(async () => {});
|
||||
},
|
||||
}));
|
||||
|
||||
import { isTextLike, isPdf, extractText } from '../../../../src/nest/llm-parse/text-extract';
|
||||
|
||||
describe('text-extract', () => {
|
||||
it('classifies text-like and pdf extensions', () => {
|
||||
expect(isTextLike('a.txt')).toBe(true);
|
||||
expect(isTextLike('a.html')).toBe(true);
|
||||
expect(isTextLike('a.eml')).toBe(true);
|
||||
expect(isTextLike('a.pdf')).toBe(false);
|
||||
expect(isPdf('a.PDF')).toBe(true);
|
||||
expect(isPdf('a.txt')).toBe(false);
|
||||
});
|
||||
|
||||
it('decodes plain text', async () => {
|
||||
expect(await extractText(Buffer.from('hello world'), 'a.txt')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('strips markup from html/eml', async () => {
|
||||
const html = '<html><style>x{}</style><body><p>Flight AB123</p><script>1</script></body></html>';
|
||||
const out = await extractText(Buffer.from(html), 'a.html');
|
||||
expect(out).toContain('Flight AB123');
|
||||
expect(out).not.toContain('<p>');
|
||||
expect(out).not.toContain('x{}');
|
||||
});
|
||||
|
||||
it('extracts the embedded text layer from a pdf', async () => {
|
||||
const out = await extractText(Buffer.from('%PDF-1.4'), 'a.pdf');
|
||||
expect(out).toBe('Hotel X — confirmation ABC');
|
||||
expect(getText).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { matchVendorTemplate } from '../../../../src/nest/llm-parse/router/vendor-templates';
|
||||
import { extractBookingRef, extractTotalPrice } from '../../../../src/nest/llm-parse/router/extraction-router';
|
||||
|
||||
// The snippets below mirror the pdf-parse text layer of real confirmation PDFs
|
||||
// (Expedia hotel receipt, Airbnb booking, a broker rental-car voucher).
|
||||
|
||||
const EXPEDIA_HOTEL = `Beleg
|
||||
Expedia-Reiseplan: 73222406755286
|
||||
Buchungsdatum: 27. Aug. 2025
|
||||
Buchungsdetails
|
||||
Mercure Tokyo Haneda Airport
|
||||
1 Chome-2-11 Haneda, Ota City, Tokyo, 144-0043 Japan
|
||||
Anreise: 3. Mai 2026
|
||||
Abreise: 22. Mai 2026
|
||||
1 Zimmer x 19 Nächte
|
||||
Zahlungsdetails
|
||||
Steuern und Gebühren 1.195,07 €
|
||||
Gesamtpreis 3.516,13 €
|
||||
Bezahlt`;
|
||||
|
||||
const AIRBNB = `Zwei-Zimmer-Wohnung zwischen Venedig und
|
||||
Treviso!
|
||||
Check-in
|
||||
15:00
|
||||
Sa., 23. Aug.
|
||||
Check-out
|
||||
10:00
|
||||
Sa., 30. Aug.
|
||||
Bestätigungs-Code
|
||||
HMHJ9RTEEK
|
||||
Adresse
|
||||
Via Aldo Moro, 47 n. 15, Quarto d'Altino, Venetien 30020, Italien
|
||||
Bezahlter Betrag
|
||||
651,86 €`;
|
||||
|
||||
const BROKER_RENTAL = `Reservation No.: G72820729
|
||||
MAIN DRIVER'S NAME: Felix Pakulat
|
||||
SUPPLIER DETAILS
|
||||
SICILY BY CAR (V2) Supplier Reference: IT587200464
|
||||
PICK-UP DETAILS
|
||||
Venice Marco Polo Airport
|
||||
Aug 23 2025 13:30
|
||||
DROP-OFF DETAILS
|
||||
Venice Marco Polo Airport
|
||||
Aug 30 2025 12:30
|
||||
Payment Details
|
||||
Amount Payable to
|
||||
Supplier:
|
||||
(Payable at Pick-up)
|
||||
EUR 300.21`;
|
||||
|
||||
describe('expedia-hotel vendor template', () => {
|
||||
it('extracts hotel name, address, stay dates, price and Reiseplan number', () => {
|
||||
const out = matchVendorTemplate(EXPEDIA_HOTEL);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
type: 'hotel',
|
||||
name: 'Mercure Tokyo Haneda Airport',
|
||||
booking_reference: '73222406755286',
|
||||
address: '1 Chome-2-11 Haneda, Ota City, Tokyo, 144-0043 Japan',
|
||||
checkin_time: '2026-05-03',
|
||||
checkout_time: '2026-05-22',
|
||||
price: '3.516,13',
|
||||
currency: 'EUR',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses German abbreviated months (e.g. "4. Feb. 2026")', () => {
|
||||
const bnb = EXPEDIA_HOTEL.replace('Anreise: 3. Mai 2026', 'Anreise: 4. Feb. 2026').replace(
|
||||
'Abreise: 22. Mai 2026',
|
||||
'Abreise: 6. Feb. 2026',
|
||||
);
|
||||
const out = matchVendorTemplate(bnb);
|
||||
expect(out?.[0]).toMatchObject({ checkin_time: '2026-02-04', checkout_time: '2026-02-06' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('broker-rental-voucher vendor template', () => {
|
||||
it('extracts pickup/return depots, English date-times, price and the customer reservation no.', () => {
|
||||
const out = matchVendorTemplate(BROKER_RENTAL);
|
||||
expect(out).toEqual([
|
||||
{
|
||||
type: 'car',
|
||||
operator: 'SICILY BY CAR', // the "(V2)" supplier-version tag is stripped
|
||||
booking_reference: 'G72820729', // the customer ref, not the supplier reference
|
||||
from_name: 'Venice Marco Polo Airport',
|
||||
to_name: 'Venice Marco Polo Airport',
|
||||
departure_time: '2025-08-23T13:30:00',
|
||||
arrival_time: '2025-08-30T12:30:00',
|
||||
price: '300.21',
|
||||
currency: 'EUR',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-matching documents', () => {
|
||||
it('returns null when no template applies', () => {
|
||||
expect(matchVendorTemplate(AIRBNB)).toBeNull();
|
||||
expect(matchVendorTemplate('just some unrelated text')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('broker template — date & price variants', () => {
|
||||
const VARIANT = `Reservation No.: AB123456
|
||||
SUPPLIER DETAILS
|
||||
GREEN MOTION Supplier Reference: XYZ
|
||||
PICK-UP DETAILS
|
||||
London Heathrow
|
||||
Aug 5, 2025 09:00 AM
|
||||
DROP-OFF DETAILS
|
||||
London Heathrow
|
||||
Aug 12, 2025 05:30 PM
|
||||
Payment Details
|
||||
Total to pay
|
||||
150.00 GBP`;
|
||||
|
||||
it('handles a comma date, a 12-hour clock and a trailing non-EUR currency', () => {
|
||||
const out = matchVendorTemplate(VARIANT);
|
||||
expect(out?.[0]).toMatchObject({
|
||||
booking_reference: 'AB123456',
|
||||
departure_time: '2025-08-05T09:00:00', // 09:00 AM
|
||||
arrival_time: '2025-08-12T17:30:00', // 05:30 PM → 17:30
|
||||
price: '150.00',
|
||||
currency: 'GBP', // derived, not hard-coded EUR
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBookingRef', () => {
|
||||
it('reads an Airbnb "Bestätigungs-Code"', () => {
|
||||
expect(extractBookingRef(AIRBNB)).toBe('HMHJ9RTEEK');
|
||||
});
|
||||
it('prefers the customer "Reservation No." over a later "Supplier Reference"', () => {
|
||||
expect(extractBookingRef(BROKER_RENTAL)).toBe('G72820729');
|
||||
});
|
||||
it('still reads a classic "Buchungsnummer" / "PNR"', () => {
|
||||
expect(extractBookingRef('Buchungsnummer: ABC123')).toBe('ABC123');
|
||||
expect(extractBookingRef('PNR XY7Q9Z')).toBe('XY7Q9Z');
|
||||
});
|
||||
it('does not capture a prose word after a bare "Confirmation"/"reference"', () => {
|
||||
expect(extractBookingRef('Booking Confirmation\n\nThank you for choosing us')).toBeUndefined();
|
||||
expect(extractBookingRef('For future reference please retain this email')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTotalPrice', () => {
|
||||
it('reads an Airbnb "Bezahlter Betrag"', () => {
|
||||
expect(extractTotalPrice(AIRBNB)).toEqual({ price: '651,86', currency: 'EUR' });
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/shared",
|
||||
"version": "3.1.1",
|
||||
"version": "3.1.2",
|
||||
"private": true,
|
||||
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
|
||||
"type": "module",
|
||||
|
||||
@@ -129,6 +129,9 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.previewHeading': 'تم العثور على {count} حجز/حجوزات',
|
||||
'reservations.import.previewEmpty': 'تعذّر استخراج أي حجوزات من الملفات المُحمَّلة.',
|
||||
'reservations.import.removeItem': 'إزالة',
|
||||
'reservations.import.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'استيراد {count} حجز/حجوزات',
|
||||
'reservations.import.back': 'رجوع',
|
||||
'reservations.import.success': 'تم استيراد {count} حجز/حجوزات',
|
||||
|
||||
@@ -55,6 +55,8 @@ const settings: TranslationStrings = {
|
||||
'settings.bookingLabels': 'تسميات مسارات الحجوزات',
|
||||
'settings.bookingLabelsHint': 'عرض أسماء المحطات/المطارات على الخريطة. عند الإيقاف، يتم عرض الرمز فقط.',
|
||||
'settings.blurBookingCodes': 'إخفاء رموز الحجز',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'تحسين المسار انطلاقًا من مكان الإقامة',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'عند تحسين يوم ما، يبدأ المسار من الفندق الذي تستيقظ فيه وينتهي عند الفندق الذي تسجّل الوصول إليه في تلك الليلة.',
|
||||
|
||||
@@ -128,6 +128,9 @@ const reservations: TranslationStrings = {
|
||||
'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.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'Importar {count} reserva(s)',
|
||||
'reservations.import.back': 'Voltar',
|
||||
'reservations.import.success': '{count} reserva(s) importada(s)',
|
||||
|
||||
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': 'Unidade de temperatura',
|
||||
'settings.timeFormat': 'Formato de hora',
|
||||
'settings.blurBookingCodes': 'Ocultar códigos de reserva',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Otimizar rota a partir da hospedagem',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'Ao otimizar um dia, comece a rota no hotel onde você acorda e termine no hotel em que você faz check-in à noite.',
|
||||
|
||||
@@ -129,6 +129,9 @@ const reservations: TranslationStrings = {
|
||||
'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.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'Importovat {count} rezervaci/í',
|
||||
'reservations.import.back': 'Zpět',
|
||||
'reservations.import.success': '{count} rezervace/í importováno',
|
||||
|
||||
@@ -55,6 +55,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': 'Jednotky teploty',
|
||||
'settings.timeFormat': 'Formát času',
|
||||
'settings.blurBookingCodes': 'Skrýt rezervační kódy',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Optimalizovat trasu od ubytování',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'Při optimalizaci dne začne trasa v hotelu, ve kterém se ráno probudíte, a skončí v hotelu, do kterého se večer ubytujete.',
|
||||
|
||||
@@ -130,6 +130,9 @@ const reservations: TranslationStrings = {
|
||||
'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.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': '{count} Reservierung(en) importieren',
|
||||
'reservations.import.back': 'Zurück',
|
||||
'reservations.import.success': '{count} Reservierung(en) importiert',
|
||||
|
||||
@@ -58,6 +58,8 @@ const settings: TranslationStrings = {
|
||||
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
|
||||
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
|
||||
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Route ab der Unterkunft optimieren',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'Beim Optimieren eines Tages startet die Route an der Unterkunft, in der du aufwachst, und endet an der, in die du am Abend eincheckst.',
|
||||
|
||||
@@ -128,6 +128,9 @@ const reservations: TranslationStrings = {
|
||||
'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.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'Import {count} reservation(s)',
|
||||
'reservations.import.back': 'Back',
|
||||
'reservations.import.success': '{count} reservation(s) imported',
|
||||
|
||||
@@ -60,6 +60,8 @@ const settings: TranslationStrings = {
|
||||
'settings.mapPoiPillHint':
|
||||
'Show a category pill on the trip map to find nearby restaurants, hotels and more from OpenStreetMap.',
|
||||
'settings.blurBookingCodes': 'Blur Booking Codes',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Optimize route from accommodation',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'When optimizing a day, start the route at the hotel you wake up in and end it at the one you check into that evening.',
|
||||
|
||||
@@ -131,6 +131,9 @@ const reservations: TranslationStrings = {
|
||||
'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.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'Importar {count} reserva(s)',
|
||||
'reservations.import.back': 'Atrás',
|
||||
'reservations.import.success': '{count} reserva(s) importada(s)',
|
||||
|
||||
@@ -57,6 +57,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': 'Unidad de temperatura',
|
||||
'settings.timeFormat': 'Formato de hora',
|
||||
'settings.blurBookingCodes': 'Difuminar códigos de reserva',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Optimizar la ruta desde el alojamiento',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'Al optimizar un día, comienza la ruta en el hotel donde despiertas y termínala en aquel en el que te registras esa noche.',
|
||||
|
||||
@@ -132,6 +132,9 @@ const reservations: TranslationStrings = {
|
||||
'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.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'Importer {count} réservation(s)',
|
||||
'reservations.import.back': 'Retour',
|
||||
'reservations.import.success': '{count} réservation(s) importée(s)',
|
||||
|
||||
@@ -58,6 +58,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': 'Unité de température',
|
||||
'settings.timeFormat': "Format de l'heure",
|
||||
'settings.blurBookingCodes': 'Masquer les codes de réservation',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': "Optimiser l'itinéraire depuis l'hébergement",
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
"Lors de l'optimisation d'une journée, commencez l'itinéraire à l'hôtel où vous vous réveillez et terminez-le à celui où vous arrivez le soir.",
|
||||
|
||||
@@ -131,6 +131,9 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.previewHeading': 'Βρέθηκαν {count} κράτηση/κρατήσεις',
|
||||
'reservations.import.previewEmpty': 'Δεν ήταν δυνατή η εξαγωγή κρατήσεων από τα μεταφορτωμένα αρχεία.',
|
||||
'reservations.import.removeItem': 'Αφαίρεση',
|
||||
'reservations.import.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'Εισαγωγή {count} κράτησης/κρατήσεων',
|
||||
'reservations.import.back': 'Πίσω',
|
||||
'reservations.import.success': '{count} κράτηση/κρατήσεις εισήχθησαν',
|
||||
|
||||
@@ -62,6 +62,8 @@ const settings: TranslationStrings = {
|
||||
'settings.bookingLabelsHint':
|
||||
'Εμφάνιση ονομάτων σταθμών / αεροδρομίων στον χάρτη. Όταν είναι απενεργοποιημένο, εμφανίζεται μόνο το εικονίδιο.',
|
||||
'settings.blurBookingCodes': 'Θόλωμα Κωδικών Κρατήσεων',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Βελτιστοποίηση διαδρομής από το κατάλυμα',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'Κατά τη βελτιστοποίηση μιας ημέρας, ξεκινήστε τη διαδρομή από το ξενοδοχείο στο οποίο ξυπνάτε και τερματίστε την σε αυτό στο οποίο κάνετε check-in το ίδιο βράδυ.',
|
||||
|
||||
@@ -130,6 +130,9 @@ const reservations: TranslationStrings = {
|
||||
'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.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': '{count} foglalás importálása',
|
||||
'reservations.import.back': 'Vissza',
|
||||
'reservations.import.success': '{count} foglalás importálva',
|
||||
|
||||
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': 'Hőmérséklet egység',
|
||||
'settings.timeFormat': 'Időformátum',
|
||||
'settings.blurBookingCodes': 'Foglalási kódok elrejtése',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Útvonal optimalizálása a szállástól',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'A nap optimalizálásakor az útvonal annál a szállásnál kezdődjön, ahol felébredsz, és annál érjen véget, ahova este bejelentkezel.',
|
||||
|
||||
@@ -129,6 +129,9 @@ const reservations: TranslationStrings = {
|
||||
'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.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'Impor {count} pemesanan',
|
||||
'reservations.import.back': 'Kembali',
|
||||
'reservations.import.success': '{count} pemesanan berhasil diimpor',
|
||||
|
||||
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': 'Satuan Suhu',
|
||||
'settings.timeFormat': 'Format Waktu',
|
||||
'settings.blurBookingCodes': 'Sembunyikan Kode Pemesanan',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Optimalkan rute dari akomodasi',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'Saat mengoptimalkan suatu hari, mulai rute dari hotel tempatmu bangun pagi dan akhiri di hotel tempatmu check-in malam itu.',
|
||||
|
||||
@@ -129,6 +129,9 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.previewHeading': '{count} prenotazione/i trovata/e',
|
||||
'reservations.import.previewEmpty': 'Nessuna prenotazione è stata estratta dai file caricati.',
|
||||
'reservations.import.removeItem': 'Rimuovi',
|
||||
'reservations.import.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'Importa {count} prenotazione/i',
|
||||
'reservations.import.back': 'Indietro',
|
||||
'reservations.import.success': '{count} prenotazione/i importata/e',
|
||||
|
||||
@@ -57,6 +57,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': 'Unità di Temperatura',
|
||||
'settings.timeFormat': 'Formato Ora',
|
||||
'settings.blurBookingCodes': 'Nascondi codici di prenotazione',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': "Ottimizza il percorso dall'alloggio",
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
"Quando ottimizzi un giorno, fa iniziare il percorso dall'hotel in cui ti svegli e terminarlo in quello in cui fai il check-in quella sera.",
|
||||
|
||||
@@ -128,6 +128,9 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.previewHeading': '{count} 件の予約が見つかりました',
|
||||
'reservations.import.previewEmpty': 'アップロードされたファイルから予約を抽出できませんでした。',
|
||||
'reservations.import.removeItem': '削除',
|
||||
'reservations.import.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': '{count} 件の予約をインポート',
|
||||
'reservations.import.back': '戻る',
|
||||
'reservations.import.success': '{count} 件の予約をインポートしました',
|
||||
|
||||
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
|
||||
'settings.bookingLabels': '予約ルートのラベル',
|
||||
'settings.bookingLabelsHint': '地図に駅・空港名を表示。オフ時はアイコンのみ。',
|
||||
'settings.blurBookingCodes': '予約コードをぼかす',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': '宿泊先を起点にルートを最適化',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'その日を最適化する際、朝に目覚める宿泊先を起点にし、その晩にチェックインする宿泊先を終点としてルートを組みます。',
|
||||
|
||||
@@ -128,6 +128,9 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.previewHeading': '{count}개 예약 발견',
|
||||
'reservations.import.previewEmpty': '업로드된 파일에서 예약을 추출할 수 없었습니다.',
|
||||
'reservations.import.removeItem': '제거',
|
||||
'reservations.import.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': '{count}개 예약 가져오기',
|
||||
'reservations.import.back': '뒤로',
|
||||
'reservations.import.success': '{count}개 예약을 가져왔습니다',
|
||||
|
||||
@@ -57,6 +57,8 @@ const settings: TranslationStrings = {
|
||||
'settings.bookingLabels': '예약 경로 레이블',
|
||||
'settings.bookingLabelsHint': '지도에 역 / 공항 이름을 표시합니다. 끄면 아이콘만 표시됩니다.',
|
||||
'settings.blurBookingCodes': '예약 코드 흐리게',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': '숙소 기준으로 경로 최적화',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'하루 일정을 최적화할 때, 아침에 머무는 숙소에서 경로를 시작하고 그날 저녁에 체크인하는 숙소에서 경로를 끝냅니다.',
|
||||
|
||||
@@ -130,6 +130,9 @@ const reservations: TranslationStrings = {
|
||||
'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.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': '{count} reservering(en) importeren',
|
||||
'reservations.import.back': 'Terug',
|
||||
'reservations.import.success': '{count} reservering(en) geïmporteerd',
|
||||
|
||||
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': 'Temperatuureenheid',
|
||||
'settings.timeFormat': 'Tijdnotatie',
|
||||
'settings.blurBookingCodes': 'Boekingscodes vervagen',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Route optimaliseren vanaf accommodatie',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'Begin bij het optimaliseren van een dag de route bij het hotel waar je wakker wordt en eindig bij het hotel waar je die avond incheckt.',
|
||||
|
||||
@@ -129,6 +129,9 @@ const reservations: TranslationStrings = {
|
||||
'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.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'Importuj {count} rezerwację/rezerwacje',
|
||||
'reservations.import.back': 'Wstecz',
|
||||
'reservations.import.success': 'Zaimportowano {count} rezerwację/rezerwacje',
|
||||
|
||||
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': 'Jednostka temperatury',
|
||||
'settings.timeFormat': 'Format czasu',
|
||||
'settings.blurBookingCodes': 'Rozmyj kody rezerwacji',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Optymalizuj trasę od zakwaterowania',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'Przy optymalizacji dnia rozpocznij trasę w hotelu, w którym się budzisz, a zakończ ją w tym, do którego się zameldujesz tego wieczoru.',
|
||||
|
||||
@@ -128,6 +128,9 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.previewHeading': 'Найдено {count} бронирование(й)',
|
||||
'reservations.import.previewEmpty': 'Из загруженных файлов не удалось извлечь бронирования.',
|
||||
'reservations.import.removeItem': 'Удалить',
|
||||
'reservations.import.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'Импортировать {count} бронирование(й)',
|
||||
'reservations.import.back': 'Назад',
|
||||
'reservations.import.success': '{count} бронирование(й) импортировано',
|
||||
|
||||
@@ -55,6 +55,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': 'Единица температуры',
|
||||
'settings.timeFormat': 'Формат времени',
|
||||
'settings.blurBookingCodes': 'Скрыть коды бронирования',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Оптимизировать маршрут от места проживания',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'При оптимизации дня маршрут начинается от отеля, в котором вы просыпаетесь, и заканчивается у того, в который вы заселяетесь вечером.',
|
||||
|
||||
@@ -130,6 +130,9 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.previewHeading': '{count} rezervasyon bulundu',
|
||||
'reservations.import.previewEmpty': 'Yüklenen dosyalardan hiçbir rezervasyon çıkarılamadı.',
|
||||
'reservations.import.removeItem': 'Kaldır',
|
||||
'reservations.import.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': '{count} rezervasyonu içe aktar',
|
||||
'reservations.import.back': 'Geri',
|
||||
'reservations.import.success': '{count} rezervasyon içe aktarıldı',
|
||||
|
||||
@@ -57,6 +57,8 @@ const settings: TranslationStrings = {
|
||||
'settings.bookingLabels': 'Rezervasyon rota etiketleri',
|
||||
'settings.bookingLabelsHint': 'Haritada istasyon / havalimanı adlarını göster. Kapalıyken yalnızca simge görünür.',
|
||||
'settings.blurBookingCodes': 'Rezervasyon Kodlarını Bulanıklaştır',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Rotayı konaklamadan optimize et',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'Bir günü optimize ederken rotaya o sabah uyandığınız otelden başlayın ve akşam giriş yaptığınız otelde sonlandırın.',
|
||||
|
||||
@@ -128,6 +128,9 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.previewHeading': 'Знайдено {count} бронювання(нь)',
|
||||
'reservations.import.previewEmpty': 'З завантажених файлів не вдалося витягти бронювання.',
|
||||
'reservations.import.removeItem': 'Видалити',
|
||||
'reservations.import.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': 'Імпортувати {count} бронювання(нь)',
|
||||
'reservations.import.back': 'Назад',
|
||||
'reservations.import.success': '{count} бронювання(нь) імпортовано',
|
||||
|
||||
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': 'Одиниця температури',
|
||||
'settings.timeFormat': 'Формат часу',
|
||||
'settings.blurBookingCodes': 'Приховати коди бронювання',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': 'Оптимізувати маршрут від житла',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'Під час оптимізації дня починайте маршрут від готелю, у якому ви прокидаєтеся, і завершуйте його тим, у який ви заселяєтеся ввечері.',
|
||||
|
||||
@@ -128,6 +128,9 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.previewHeading': '找到 {count} 筆預訂',
|
||||
'reservations.import.previewEmpty': '無法從上傳的檔案中提取任何預訂資訊。',
|
||||
'reservations.import.removeItem': '移除',
|
||||
'reservations.import.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': '匯入 {count} 筆預訂',
|
||||
'reservations.import.back': '返回',
|
||||
'reservations.import.success': '已匯入 {count} 筆預訂',
|
||||
|
||||
@@ -54,6 +54,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': '溫度單位',
|
||||
'settings.timeFormat': '時間格式',
|
||||
'settings.blurBookingCodes': '模糊預訂程式碼',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': '從住宿地點最佳化路線',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
'最佳化某一天的行程時,路線從你早上起床的飯店出發,並在你當晚入住的飯店結束。',
|
||||
|
||||
@@ -128,6 +128,9 @@ const reservations: TranslationStrings = {
|
||||
'reservations.import.previewHeading': '找到 {count} 个预订',
|
||||
'reservations.import.previewEmpty': '无法从上传的文件中提取任何预订信息。',
|
||||
'reservations.import.removeItem': '移除',
|
||||
'reservations.import.needsReview': 'Review',
|
||||
'reservations.import.tryAi': 'Try AI parsing',
|
||||
'reservations.import.aiParsing': 'Parsing with AI…',
|
||||
'reservations.import.confirm': '导入 {count} 个预订',
|
||||
'reservations.import.back': '返回',
|
||||
'reservations.import.success': '已导入 {count} 个预订',
|
||||
|
||||
@@ -54,6 +54,8 @@ const settings: TranslationStrings = {
|
||||
'settings.temperature': '温度单位',
|
||||
'settings.timeFormat': '时间格式',
|
||||
'settings.blurBookingCodes': '模糊预订代码',
|
||||
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
|
||||
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
|
||||
'settings.optimizeFromAccommodation': '从住宿地优化路线',
|
||||
'settings.optimizeFromAccommodationHint': '优化某一天时,路线将从您醒来时所在的酒店出发,并在当晚入住的酒店结束。',
|
||||
'settings.notifications': '通知',
|
||||
|
||||
@@ -26,6 +26,7 @@ export * from './packing/packing.schema';
|
||||
export * from './todo/todo.schema';
|
||||
export * from './budget/budget.schema';
|
||||
export * from './reservation/reservation.schema';
|
||||
export * from './reservation/ki-reservation.schema';
|
||||
export * from './airtrail/airtrail.schema';
|
||||
export * from './day/day.schema';
|
||||
export * from './assignment/assignment.schema';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user