mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c8ff2d2ff | |||
| 0d6737726d | |||
| 6996a67670 | |||
| 84adc28684 | |||
| f206fa6dff | |||
| c3b3c278b8 | |||
| d09a62fcc8 | |||
| f4b2143a59 | |||
| 33f554b1bf | |||
| fc1f29bb29 | |||
| 01e5859564 | |||
| 6a70f4fc41 | |||
| 27fbc241e8 | |||
| 574c54c16c | |||
| 0cb0567d28 | |||
| 76447f4a73 | |||
| 55ff5c03dd | |||
| 3277965426 | |||
| d95d26e493 | |||
| 4abe96fe01 | |||
| 7bac753ff3 | |||
| 743397994e | |||
| 459426ed43 | |||
| b3fa87bdd6 | |||
| 519dc3b0d8 | |||
| c1d61c98f0 | |||
| c7f5694f63 | |||
| d0b4052c5d | |||
| 1c81e8b959 | |||
| 8f1c99a07a | |||
| 5fdd4aa153 | |||
| 22801938b5 | |||
| 8640100312 | |||
| e666313865 | |||
| aa72d527c9 | |||
| 684ac3b442 | |||
| f049229e25 | |||
| 38565c3c6d | |||
| a1cbc11169 | |||
| b859ae8b00 | |||
| ae14a6c860 | |||
| 41c541828f | |||
| 37f1fff367 | |||
| 0c1c534435 |
+5
-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
|
||||
@@ -89,6 +77,8 @@ COPY server/tsconfig.json ./server/
|
||||
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
||||
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
||||
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
|
||||
COPY server/reset-admin.js ./server/reset-admin.js
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
@@ -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'
|
||||
@@ -442,6 +443,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),
|
||||
@@ -625,17 +661,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,225 @@ 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 drives
|
||||
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
|
||||
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
|
||||
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
|
||||
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, 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
|
||||
)
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import type { BudgetItem } from '../../types'
|
||||
* button (the modal saves the booking first, then opens the full Costs editor);
|
||||
* once linked it shows the expense with edit / remove actions.
|
||||
*/
|
||||
export function BookingCostsSection({ reservationId, onCreate, onEdit, onRemove }: {
|
||||
export function BookingCostsSection({ reservationId, pendingExpense, onCreate, onEdit, onRemove }: {
|
||||
reservationId: number | null
|
||||
/** A cost parsed from an import that will be linked on save — previewed before the booking exists. */
|
||||
pendingExpense?: { total_price: number; currency?: string | null; category: string } | null
|
||||
onCreate: () => void
|
||||
onEdit: (item: BudgetItem) => void
|
||||
onRemove: (item: BudgetItem) => void
|
||||
@@ -27,6 +29,25 @@ export function BookingCostsSection({ reservationId, onCreate, onEdit, onRemove
|
||||
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
|
||||
// Import review (booking not saved yet): preview the parsed cost that will be linked on save.
|
||||
if (!linked && pendingExpense && pendingExpense.total_price > 0) {
|
||||
const meta = catMeta(pendingExpense.category)
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
||||
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{t(meta.labelKey)}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 12 }}>{t('reservations.createExpenseHint')}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(pendingExpense.total_price, pendingExpense.currency || base, locale)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (linked) {
|
||||
const meta = catMeta(linked.category)
|
||||
const Icon = meta.Icon
|
||||
|
||||
@@ -1,81 +1,44 @@
|
||||
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'
|
||||
import { saveImportFiles } from '../../db/offlineDb'
|
||||
|
||||
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 +47,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 +89,44 @@ 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)
|
||||
// Keep the uploaded files so the review can attach each source document to its booking —
|
||||
// in memory for the immediate path, and in IndexedDB so it survives a reload mid-parse.
|
||||
await saveImportFiles(jobId, files)
|
||||
addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length, files })
|
||||
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 +136,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 +190,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>,
|
||||
|
||||
@@ -79,6 +79,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const fileRef = useRef(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||
const [acHighlight, setAcHighlight] = useState(-1)
|
||||
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -131,6 +132,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [place, prefillCoords, isOpen, assignmentId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
const modal = searchInputRef.current?.closest('[role="dialog"]') ?? document.body
|
||||
if (!modal.contains(document.activeElement) || document.activeElement === document.body) {
|
||||
searchInputRef.current?.focus()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Derive location bias bounding box from the trip's existing places
|
||||
const places = useTripStore((s) => s.places)
|
||||
const locationBias = useMemo(() => {
|
||||
@@ -436,6 +448,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
canUploadFiles,
|
||||
places,
|
||||
locationBias,
|
||||
searchInputRef,
|
||||
fetchSuggestions,
|
||||
handleChange,
|
||||
handleMapsSearch,
|
||||
@@ -497,6 +510,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
canUploadFiles,
|
||||
places,
|
||||
locationBias,
|
||||
searchInputRef,
|
||||
fetchSuggestions,
|
||||
handleChange,
|
||||
handleMapsSearch,
|
||||
@@ -548,6 +562,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={mapsSearch}
|
||||
onChange={e => setMapsSearch(e.target.value)}
|
||||
|
||||
@@ -11,9 +11,11 @@ import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import { resolveDayId } from '../../utils/formatters'
|
||||
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 +66,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,10 +89,11 @@ 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)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||
|
||||
@@ -97,6 +103,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// 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 +125,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 +141,53 @@ 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: resolveDayId(days, prefill._accommodation?.check_in),
|
||||
hotel_end_day: resolveDayId(days, prefill._accommodation?.check_out),
|
||||
hotel_address: prefill._venue?.address || '',
|
||||
})
|
||||
// Seed the booking's Files with the document this item was parsed from.
|
||||
setPendingFiles(prefill._sourceFiles ?? [])
|
||||
} 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 +243,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) {
|
||||
@@ -234,6 +299,13 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
|
||||
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
|
||||
const prefillPrice = Number(prefillMeta?.price)
|
||||
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
|
||||
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
|
||||
: null
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
@@ -497,6 +569,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>
|
||||
@@ -615,6 +692,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
{isBudgetEnabled && (
|
||||
<BookingCostsSection
|
||||
reservationId={reservation?.id ?? null}
|
||||
pendingExpense={pendingExpense}
|
||||
onCreate={handleCreateExpense}
|
||||
onEdit={handleEditExpense}
|
||||
onRemove={handleRemoveExpense}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,13 +10,14 @@ import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
|
||||
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'))
|
||||
@@ -153,26 +157,34 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
|
||||
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
|
||||
// On a review-import, seed the booking's Files with the parsed source document.
|
||||
setPendingFiles(!reservation && prefill?._sourceFiles ? prefill._sourceFiles : [])
|
||||
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 ?? resolveDayId(days, splitReservationDateTime(src.reservation_time).date),
|
||||
end_day_id: src.end_day_id ?? resolveDayId(days, 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 +192,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 +203,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 +214,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 +236,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 +340,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) {
|
||||
@@ -359,6 +380,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
// On an import review (not yet saved), preview the parsed price as the cost that will be linked.
|
||||
const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : null
|
||||
const prefillPrice = Number(prefillMeta?.price)
|
||||
const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0
|
||||
? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) }
|
||||
: null
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
@@ -719,6 +747,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
{isBudgetEnabled && (
|
||||
<BookingCostsSection
|
||||
reservationId={reservation?.id ?? null}
|
||||
pendingExpense={pendingExpense}
|
||||
onCreate={handleCreateExpense}
|
||||
onEdit={handleEditExpense}
|
||||
onRemove={handleRemoveExpense}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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']
|
||||
/** The uploaded source file(s) the item was parsed from — attached to the booking on save. */
|
||||
_sourceFiles?: File[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { authApi, oauthApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
import AirTrailConnectionSection from './AirTrailConnectionSection'
|
||||
import LlmConnectionSection from './LlmConnectionSection'
|
||||
import { ALL_SCOPES } from '../../api/oauthScopes'
|
||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||
|
||||
@@ -99,6 +100,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
<>
|
||||
<PhotoProvidersSection />
|
||||
{S.airtrailEnabled && <AirTrailConnectionSection />}
|
||||
{S.llmEnabled && <LlmConnectionSection />}
|
||||
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
|
||||
<McpTokenModals {...S} />
|
||||
<OAuthClientModals {...S} />
|
||||
@@ -112,6 +114,7 @@ function useIntegrations() {
|
||||
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const airtrailEnabled = addonEnabled('airtrail')
|
||||
const llmEnabled = addonEnabled('llm_parsing')
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
@@ -292,7 +295,7 @@ function useIntegrations() {
|
||||
|
||||
|
||||
return {
|
||||
t, locale, toast, mcpEnabled, airtrailEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
||||
t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Sparkles, Save } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Settings } from '../../types'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
type Provider = NonNullable<Settings['llm_provider']>
|
||||
|
||||
/**
|
||||
* Settings → Integrations → AI parsing. Per-user model used to extract bookings
|
||||
* from uploaded files. It only takes effect when the admin has not configured an
|
||||
* instance-wide model on the addon — the server resolves the admin config first.
|
||||
* The API key is stored encrypted and never prefilled: a blank field keeps the
|
||||
* stored key (mirrors the AirTrail connection layout).
|
||||
*/
|
||||
export default function LlmConnectionSection(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
const isLoaded = useSettingsStore(s => s.isLoaded)
|
||||
const updateSettings = useSettingsStore(s => s.updateSettings)
|
||||
|
||||
const [provider, setProvider] = useState<Provider>('local')
|
||||
const [model, setModel] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [multimodal, setMultimodal] = useState(false)
|
||||
const [hasStoredKey, setHasStoredKey] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Hydrate from the loaded settings. llm_api_key arrives masked, so we only use
|
||||
// its presence to drive the placeholder — never the value itself.
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return
|
||||
setProvider(settings.llm_provider || 'local')
|
||||
setModel(settings.llm_model || '')
|
||||
setBaseUrl(settings.llm_base_url || '')
|
||||
setMultimodal(settings.llm_multimodal === true)
|
||||
setHasStoredKey(!!settings.llm_api_key)
|
||||
}, [isLoaded, settings.llm_provider, settings.llm_model, settings.llm_base_url, settings.llm_multimodal, settings.llm_api_key])
|
||||
|
||||
const needsKey = provider !== 'local'
|
||||
const showBaseUrl = provider === 'local' || provider === 'openai'
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: Partial<Settings> = {
|
||||
llm_provider: provider,
|
||||
llm_model: model.trim(),
|
||||
llm_base_url: showBaseUrl ? baseUrl.trim() : '',
|
||||
llm_multimodal: multimodal,
|
||||
}
|
||||
// Send the key only when the user typed a new one — a blank field means
|
||||
// "keep the stored key".
|
||||
const key = apiKey.trim()
|
||||
if (key) payload.llm_api_key = key
|
||||
await updateSettings(payload)
|
||||
setApiKey('')
|
||||
if (key) setHasStoredKey(true)
|
||||
toast.success(t('settings.aiParsing.toast.saved'))
|
||||
} catch {
|
||||
toast.error(t('settings.aiParsing.toast.saveError'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('settings.aiParsing.title')} icon={Sparkles}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-content-secondary">{t('settings.aiParsing.hint')}</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.provider')}</label>
|
||||
<CustomSelect
|
||||
value={provider}
|
||||
onChange={v => setProvider(v as Provider)}
|
||||
options={[
|
||||
{ value: 'local', label: t('settings.aiParsing.providerLocal') },
|
||||
{ value: 'openai', label: t('settings.aiParsing.providerOpenai') },
|
||||
{ value: 'anthropic', label: t('settings.aiParsing.providerAnthropic') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.model')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder="qwen3:8b"
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showBaseUrl && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.baseUrl')}</label>
|
||||
<input
|
||||
type="url"
|
||||
value={baseUrl}
|
||||
onChange={e => setBaseUrl(e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.baseUrlHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsKey && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">{t('settings.aiParsing.apiKey')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => setApiKey(e.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder={hasStoredKey && !apiKey ? '••••••••' : t('settings.aiParsing.apiKey')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 border-edge bg-surface-secondary text-content"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.apiKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={multimodal} onToggle={() => setMultimodal(v => !v)} />
|
||||
<span className="text-sm font-medium text-content-secondary">{t('settings.aiParsing.multimodal')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-faint">{t('settings.aiParsing.multimodalHint')}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !isLoaded}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -60,6 +60,15 @@ export interface BlobCacheEntry {
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
/** An uploaded booking-import source file, kept so the review flow can attach it to the
|
||||
* created bookings even after a page reload during the (background) parse. Keyed by job. */
|
||||
export interface ImportSourceFile {
|
||||
jobId: string;
|
||||
fileName: string;
|
||||
blob: Blob;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// ── Dexie class ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -105,6 +114,7 @@ class TrekOfflineDb extends Dexie {
|
||||
mutationQueue!: Table<QueuedMutation, string>;
|
||||
syncMeta!: Table<SyncMeta, number>;
|
||||
blobCache!: Table<BlobCacheEntry, string>;
|
||||
importFiles!: Table<ImportSourceFile, [string, string]>;
|
||||
|
||||
constructor(name: string = ANON_DB_NAME) {
|
||||
super(name);
|
||||
@@ -140,6 +150,11 @@ class TrekOfflineDb extends Dexie {
|
||||
if (row.bytes == null) row.bytes = row.blob?.size ?? 0;
|
||||
});
|
||||
});
|
||||
|
||||
// v4: durable store for booking-import source files (survives a reload mid-parse).
|
||||
this.version(4).stores({
|
||||
importFiles: '[jobId+fileName], jobId, createdAt',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +279,39 @@ export async function getCachedBlob(url: string): Promise<Blob | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Booking-import source files ─────────────────────────────────────────────
|
||||
|
||||
/** Abandoned import files (never reviewed) are pruned after this long. */
|
||||
const IMPORT_FILE_TTL_MS = 60 * 60_000;
|
||||
|
||||
/**
|
||||
* Persist the uploaded source files for a background import job so the per-item review can
|
||||
* attach each document to its booking even if the page reloads during the parse. Best-effort.
|
||||
*/
|
||||
export async function saveImportFiles(jobId: string, files: File[]): Promise<void> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
await offlineDb.importFiles.bulkPut(files.map(f => ({ jobId, fileName: f.name, blob: f, createdAt: now })));
|
||||
// Prune leftovers from imports that were never reviewed.
|
||||
await offlineDb.importFiles.where('createdAt').below(now - IMPORT_FILE_TTL_MS).delete();
|
||||
} catch { /* the in-memory copy still serves the no-reload path */ }
|
||||
}
|
||||
|
||||
/** A job's stored source files, rebuilt as File objects (name + type preserved for upload). */
|
||||
export async function getImportFiles(jobId: string): Promise<File[]> {
|
||||
try {
|
||||
const rows = await offlineDb.importFiles.where('jobId').equals(jobId).toArray();
|
||||
return rows.map(r => new File([r.blob], r.fileName, { type: r.blob.type || 'application/octet-stream' }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a job's stored source files once they've been handed to the review flow. */
|
||||
export async function deleteImportFiles(jobId: string): Promise<void> {
|
||||
try { await offlineDb.importFiles.where('jobId').equals(jobId).delete(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Blob-cache budget ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
@@ -35,7 +35,6 @@ import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen,
|
||||
import { useTranslation } from '../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
|
||||
import { accommodationRepo } from '../repo/accommodationRepo'
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { useResizablePanels } from '../hooks/useResizablePanels'
|
||||
@@ -195,6 +194,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
bookingForAssignmentId, setBookingForAssignmentId,
|
||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||
transportModalDayId, setTransportModalDayId,
|
||||
reservationPrefill, transportPrefill, importReviewActive, advanceImportReview,
|
||||
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
|
||||
@@ -699,8 +699,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 +713,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}
|
||||
|
||||
@@ -17,7 +17,8 @@ export function useSettings() {
|
||||
const memoriesEnabled = addonEnabled('memories')
|
||||
const mcpEnabled = addonEnabled('mcp')
|
||||
const airtrailEnabled = addonEnabled('airtrail')
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled
|
||||
const llmEnabled = addonEnabled('llm_parsing')
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled
|
||||
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('display')
|
||||
|
||||
@@ -7,9 +7,12 @@ 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 { offlineDb, getImportFiles, deleteImportFiles } from '../../db/offlineDb'
|
||||
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useResizablePanels } from '../../hooks/useResizablePanels'
|
||||
import { useTripWebSocket } from '../../hooks/useTripWebSocket'
|
||||
@@ -158,6 +161,14 @@ 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[]>([])
|
||||
// The files this import was parsed from, so each reviewed booking can attach its source doc.
|
||||
const importSourceFilesRef = useRef<File[]>([])
|
||||
// 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 +589,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
|
||||
@@ -596,6 +614,9 @@ export function useTripPlanner() {
|
||||
const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('trip.toast.reservationAdded'))
|
||||
setShowReservationModal(false)
|
||||
// An imported booking auto-creates a linked cost server-side; the saving client gets
|
||||
// no budget:created echo, so refresh the budget items here to surface it without a reload.
|
||||
if ((data as Record<string, unknown>).create_budget_entry) await tripActions.loadBudgetItems?.(tripId)
|
||||
// Refresh accommodations if hotel was created
|
||||
if (data.type === 'hotel') {
|
||||
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||
@@ -620,6 +641,8 @@ export function useTripPlanner() {
|
||||
setShowTransportModal(false)
|
||||
setEditingTransport(null)
|
||||
setTransportModalDayId(null)
|
||||
// Surface the auto-created linked cost without a reload (no budget:created echo to us).
|
||||
if (data.create_budget_entry) await tripActions.loadBudgetItems?.(tripId)
|
||||
return r
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
@@ -635,6 +658,108 @@ 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)
|
||||
// Attach the file this item was parsed from so it lands in the booking's Files on save.
|
||||
const srcName = item.source?.fileName
|
||||
const srcFile = srcName ? importSourceFilesRef.current.find(f => f.name === srcName) : undefined
|
||||
if (srcFile) draft._sourceFiles = [srcFile]
|
||||
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[], sourceFiles: File[] = []) => {
|
||||
if (!items.length) return
|
||||
importSourceFilesRef.current = sourceFiles
|
||||
importQueueRef.current = items.slice(1)
|
||||
setImportReviewActive(true)
|
||||
openImportItem(items[0])
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Lives in the hook so the page stays a pure wiring container.
|
||||
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 (and the source files, to attach to each booking) to the review flow
|
||||
// and clear the widget entry — once the user hit "review", the background card is done.
|
||||
const items = task.items
|
||||
const jobId = task.id
|
||||
const inMemory = task.sourceFiles
|
||||
dismissBgTask(jobId)
|
||||
// Prefer the in-memory files (immediate path); after a reload they live in IndexedDB.
|
||||
void (async () => {
|
||||
const files = inMemory && inMemory.length ? inMemory : await getImportFiles(jobId)
|
||||
deleteImportFiles(jobId)
|
||||
startImportReview(items, files)
|
||||
})()
|
||||
}
|
||||
}, [bgTasks, tripId, startImportReview, dismissBgTask])
|
||||
|
||||
// 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 +818,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,86 @@
|
||||
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
|
||||
/** The uploaded files this parse ran on — kept in memory so the review can attach the
|
||||
* source document to each created booking. Not persisted (a File can't survive a reload). */
|
||||
sourceFiles?: File[]
|
||||
}
|
||||
|
||||
interface BackgroundTasksState {
|
||||
tasks: BackgroundImportTask[]
|
||||
addTask: (task: { id: string; tripId: string; label: string; total: number; files?: File[] }) => 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, files }) => upsert(id, tripId, { label, total, status: 'running', done: 0, sourceFiles: files }),
|
||||
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 })),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -128,6 +128,13 @@ export interface Settings {
|
||||
dashboard_fx_from?: string
|
||||
dashboard_fx_to?: string
|
||||
dashboard_timezones?: string[]
|
||||
// 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 {
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitReservationDateTime } from './formatters'
|
||||
import { splitReservationDateTime, resolveDayId } from './formatters'
|
||||
import type { Day } from '../types'
|
||||
|
||||
const days = [
|
||||
{ id: 10, date: '2026-05-03' },
|
||||
{ id: 11, date: '2026-05-04' },
|
||||
{ id: 12, date: '2026-05-22' },
|
||||
] as Day[]
|
||||
|
||||
describe('resolveDayId', () => {
|
||||
it('returns the exact-match day id', () => {
|
||||
expect(resolveDayId(days, '2026-05-04')).toBe(11)
|
||||
})
|
||||
it('accepts a full ISO timestamp', () => {
|
||||
expect(resolveDayId(days, '2026-05-22T13:30:00')).toBe(12)
|
||||
})
|
||||
it('falls back to the nearest day when there is no exact match', () => {
|
||||
expect(resolveDayId(days, '2026-05-05')).toBe(11)
|
||||
})
|
||||
it('returns "" for a missing/invalid date or no days', () => {
|
||||
expect(resolveDayId(days, null)).toBe('')
|
||||
expect(resolveDayId(days, 'not a date')).toBe('')
|
||||
expect(resolveDayId([], '2026-05-04')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitReservationDateTime', () => {
|
||||
it('parses full ISO datetime', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssignmentsMap } from '../types'
|
||||
import type { AssignmentsMap, Day } from '../types'
|
||||
|
||||
// Collapses verbose Nominatim display_name strings (e.g. "Place, 1, Road, Neighbourhood,
|
||||
// City, County, State, Country, Postcode, Country") into "Place, Postcode, Country".
|
||||
@@ -129,6 +129,27 @@ export function splitReservationDateTime(value?: string | null): { date: string
|
||||
return { date: null, time: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a date (YYYY-MM-DD or an ISO timestamp) to a trip day id: exact match, else the
|
||||
* nearest day so an out-of-range booking still lands on one. Returns '' when there is no
|
||||
* usable date or the trip has no days — callers read that as "no day selected".
|
||||
*/
|
||||
export function resolveDayId(days: Day[], value: string | null | undefined): Day['id'] | '' {
|
||||
const date = value ? String(value).slice(0, 10) : ''
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || days.length === 0) return ''
|
||||
const exact = days.find(d => d.date === date)
|
||||
if (exact) return exact.id
|
||||
const target = new Date(date).getTime()
|
||||
let best: Day['id'] | '' = ''
|
||||
let bestDiff = Infinity
|
||||
for (const d of days) {
|
||||
if (!d.date) continue
|
||||
const diff = Math.abs(new Date(d.date).getTime() - target)
|
||||
if (diff < bestDiff) { bestDiff = diff; best = d.id }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
||||
const da = assignments[String(dayId)] || []
|
||||
const total = da.reduce((s, a) => s + (parseFloat(String(a.place?.price ?? '')) || 0), 0)
|
||||
|
||||
@@ -11,6 +11,8 @@ vi.mock('../src/api/websocket', () => ({
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
// MSW lifecycle
|
||||
|
||||
Generated
+217
@@ -4481,6 +4481,190 @@
|
||||
"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"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"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",
|
||||
@@ -15436,6 +15620,38 @@
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -20907,6 +21123,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",
|
||||
|
||||
+6
-4
@@ -43,8 +43,10 @@ DEMO_MODE=false # Demo mode - resets data hourly
|
||||
# OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less.
|
||||
# OVERPASS_TIMEOUT_MS=12000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. Raise it for a slow self-hosted instance. (default: 12000)
|
||||
|
||||
# Initial admin account — only used on first boot when no users exist yet.
|
||||
# If both are set the admin account is created with these credentials.
|
||||
# If either is omitted a random password is generated and printed to the server log.
|
||||
# Initial admin account — ONLY applied on the first boot, when the database has no
|
||||
# users yet. Adding these later has no effect (the server logs a reminder if you do);
|
||||
# to change an existing password sign in and use Settings, or reset the admin account.
|
||||
# Both must be set together. If either is omitted, a random password is generated and
|
||||
# printed to the server log under "First Run: Admin Account Created" — watch for it.
|
||||
# ADMIN_EMAIL=admin@trek.local
|
||||
# ADMIN_PASSWORD=changeme
|
||||
# ADMIN_PASSWORD=change-me-before-first-boot
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"build": "node scripts/build.mjs",
|
||||
"start:prod": "node --require tsconfig-paths/register dist/index.js",
|
||||
"reset-admin": "node reset-admin.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
@@ -42,6 +43,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",
|
||||
|
||||
+38
-9
@@ -1,21 +1,50 @@
|
||||
/**
|
||||
* Admin recovery — reset (or create) an admin account when you are locked out.
|
||||
*
|
||||
* Usage inside the container:
|
||||
* docker exec -it trek node server/reset-admin.js
|
||||
* docker exec -it -e RESET_ADMIN_EMAIL=me@example.com -e RESET_ADMIN_PASSWORD=secret trek node server/reset-admin.js
|
||||
*
|
||||
* Defaults to admin@trek.local with a generated password (printed below). The
|
||||
* account is flagged must_change_password, so you are prompted to set a new one
|
||||
* on first login. Honours TREK_DB_FILE the same way the server does.
|
||||
*/
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const Database = require('better-sqlite3');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const dbPath = path.join(__dirname, 'data/travel.db');
|
||||
// Kept in sync with the seeder/authService cost factor.
|
||||
const BCRYPT_COST = 12;
|
||||
|
||||
const email = process.env.RESET_ADMIN_EMAIL || 'admin@trek.local';
|
||||
const password = process.env.RESET_ADMIN_PASSWORD || crypto.randomBytes(12).toString('base64url');
|
||||
const generated = !process.env.RESET_ADMIN_PASSWORD;
|
||||
|
||||
const dbPath = process.env.TREK_DB_FILE || path.join(__dirname, 'data/travel.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
const hash = bcrypt.hashSync('admin123', 10);
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get('admin@admin.com');
|
||||
const hash = bcrypt.hashSync(password, BCRYPT_COST);
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
||||
|
||||
if (existing) {
|
||||
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE email = ?')
|
||||
.run(hash, 'admin', 'admin@admin.com');
|
||||
console.log('✓ Admin-Passwort zurückgesetzt: admin@admin.com / admin123');
|
||||
db.prepare('UPDATE users SET password_hash = ?, role = ?, must_change_password = 1 WHERE email = ?')
|
||||
.run(hash, 'admin', email);
|
||||
console.log(`\n✓ Admin password reset: ${email}`);
|
||||
} else {
|
||||
db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
|
||||
.run('admin', 'admin@admin.com', hash, 'admin');
|
||||
console.log('✓ Admin-User erstellt: admin@admin.com / admin123');
|
||||
// 'admin' is usually taken by the first-run seed — pick the first free username
|
||||
// so the insert can't trip the UNIQUE(username) constraint.
|
||||
let username = 'admin';
|
||||
let n = 1;
|
||||
while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(username)) {
|
||||
username = `admin${n++}`;
|
||||
}
|
||||
db.prepare('INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)')
|
||||
.run(username, email, hash, 'admin');
|
||||
console.log(`\n✓ Admin account created: ${email} (username: ${username})`);
|
||||
}
|
||||
|
||||
if (generated) console.log(` Password: ${password}`);
|
||||
console.log(' You will be asked to change the password on first login.\n');
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -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];
|
||||
|
||||
+24
-7
@@ -15,8 +15,21 @@ function isOidcOnlyConfigured(): boolean {
|
||||
|
||||
function seedAdminAccount(db: Database.Database): void {
|
||||
try {
|
||||
const env_admin_email = process.env.ADMIN_EMAIL;
|
||||
const env_admin_pw = process.env.ADMIN_PASSWORD;
|
||||
const adminEnvProvided = !!(env_admin_email || env_admin_pw);
|
||||
|
||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||
if (userCount > 0) return;
|
||||
if (userCount > 0) {
|
||||
// ADMIN_EMAIL/ADMIN_PASSWORD only take effect on the first run (empty database). Once a
|
||||
// user exists they are silently ignored — a common trip-up: people add the vars after the
|
||||
// fact, restart, nothing changes, and there is no hint why. Say so instead of staying silent.
|
||||
if (adminEnvProvided) {
|
||||
console.warn('[admin] ADMIN_EMAIL/ADMIN_PASSWORD are set, but users already exist — these only apply on first run (empty database) and are being ignored.');
|
||||
console.warn('[admin] Change an existing password from Settings after signing in, reset the admin (see the Troubleshooting wiki), or start with an empty data volume to re-run setup.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this.
|
||||
// Creating a first-run admin here would grab username 'admin' first and make the demo
|
||||
@@ -35,15 +48,18 @@ function seedAdminAccount(db: Database.Database): void {
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const env_admin_email = process.env.ADMIN_EMAIL;
|
||||
const env_admin_pw = process.env.ADMIN_PASSWORD;
|
||||
|
||||
let password;
|
||||
let email;
|
||||
let password: string;
|
||||
let email: string;
|
||||
if (env_admin_email && env_admin_pw) {
|
||||
password = env_admin_pw;
|
||||
email = env_admin_email;
|
||||
} else {
|
||||
// A partial config (only one of the two) is an easy mistake: neither value is used and a
|
||||
// generated password is created instead. Flag it so the chosen credentials silently not
|
||||
// working isn't a surprise.
|
||||
if (adminEnvProvided) {
|
||||
console.warn('[admin] Only one of ADMIN_EMAIL/ADMIN_PASSWORD is set — both are required for a custom admin. Falling back to admin@trek.local with a generated password (shown below).');
|
||||
}
|
||||
password = crypto.randomBytes(12).toString('base64url');
|
||||
email = 'admin@trek.local';
|
||||
}
|
||||
@@ -104,6 +120,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);
|
||||
@@ -154,4 +171,4 @@ function runSeeds(db: Database.Database): void {
|
||||
seedAddons(db);
|
||||
}
|
||||
|
||||
export { runSeeds };
|
||||
export { runSeeds, seedAdminAccount };
|
||||
|
||||
@@ -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,275 @@
|
||||
/**
|
||||
* 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';
|
||||
if (s.includes('¥') || /\bJPY\b/.test(s)) return 'JPY';
|
||||
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,163 @@
|
||||
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, detectFlightNumbers } 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);
|
||||
// Cap the text fed to the model. A flight itinerary lists its legs throughout a long
|
||||
// document, so it keeps a generous window; a single booking has the essentials up top,
|
||||
// so cap it tighter to keep CPU prompt-eval fast (a 11-page rental voucher was ~200s at
|
||||
// 16k, the booking data sits in the first ~2k). Cloud single-shot keeps the tight cap.
|
||||
const MAX_EXTRACT_CHARS =
|
||||
config.provider !== 'local' ? 4000 : detectFlightNumbers(input.text).length > 0 ? 16000 : 6000;
|
||||
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 — tuned for ONE model call per document.
|
||||
*
|
||||
* 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, with a type-specific schema when the
|
||||
* type is obvious from keywords (the common case), else a union schema the model picks;
|
||||
* 2. booking-wide fields (PNR, total price, currency) and the overnight-arrival day are filled
|
||||
* DETERMINISTICALLY from the text — the model isn't asked to reason about them, and the
|
||||
* document's own currency symbol corrects the model where it misreads it.
|
||||
*
|
||||
* A capable instruct model (e.g. Qwen3-8B with thinking disabled) reads name/address/dates/
|
||||
* legs reliably across formats, so there's no per-vendor template layer to drift or distort —
|
||||
* the model handles the long tail and Schicht 2 backstops the money/reference fields. 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, type FlatLike } from './flat-schemas';
|
||||
import { extractEnforced } from './ollama-format.client';
|
||||
|
||||
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. `price`/`currency` are the total
|
||||
* paid and its currency on every type; `address` is the venue street address for stays/venues. */
|
||||
const TYPE_HINT: Record<FlatType, string> = {
|
||||
flight: 'flight. vehicle_number = flight number, from_code/to_code = IATA codes, times = full ISO, price/currency = total fare.',
|
||||
train: 'train. from_name/to_name = stations, vehicle_number = train number, times = full ISO, price/currency = total fare.',
|
||||
bus: 'bus. from_name/to_name = stops, times = full ISO, price/currency = total fare.',
|
||||
ferry: 'ferry/cruise. from_name/to_name = terminals/ports, times = full ISO, price/currency = total fare.',
|
||||
car: 'rental car. operator = the rental company, from_name = pick-up location, to_name = return location (may differ), departure_time = pick-up, arrival_time = return, price/currency = total rental cost.',
|
||||
hotel: 'hotel stay. name = hotel name, address = the hotel street address, checkin_time/checkout_time = full ISO date-time, price/currency = total paid.',
|
||||
restaurant: 'restaurant booking. name = the restaurant, address = its street address, start_time = the reservation date-time, price/currency = total if shown.',
|
||||
event: 'event/attraction. name = the event/ticket, address = the venue, start_time/end_time = full ISO, price/currency = ticket price.',
|
||||
};
|
||||
|
||||
/** 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)|(?:Expedia[-\s]*)?Reiseplan|Reference)\s*:?\s*((?=[A-Z0-9]*\d)[A-Z0-9]{5,})/i,
|
||||
);
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
/** Currency symbol/code → ISO 4217, or undefined when none is recognised. */
|
||||
export function normCurrency(token: string): string | undefined {
|
||||
const u = token.toUpperCase();
|
||||
if (u.includes('€')) return 'EUR';
|
||||
if (u.includes('$')) return 'USD';
|
||||
if (u.includes('£')) return 'GBP';
|
||||
if (u.includes('¥')) return 'JPY';
|
||||
return /^[A-Z]{3}$/.test(u) ? u : undefined;
|
||||
}
|
||||
|
||||
/** The booking total, pulled deterministically (raw amount string + ISO currency). */
|
||||
export function extractTotalPrice(text: string): { price: string; currency?: string } | null {
|
||||
const strip = (s: string) => s.replace(/[€$£¥\s]/g, '');
|
||||
// A labeled total: "Gesamtpreis: 1.234,56 €", "Total Amount 99 USD", "Bezahlter Betrag 651,86 €".
|
||||
const labeled = text.match(
|
||||
/(?:Gesamtpreis|Gesamtbetrag|Gesamtsumme|Total(?:\s*(?:price|amount))?|Amount|Summe|Betrag)\s*:?\s*([€$£¥]?\s*\d[\d.,]*)\s*(EUR|USD|GBP|CHF|JPY|€|\$|£|¥)?/i,
|
||||
);
|
||||
if (labeled) return { price: strip(labeled[1]), currency: normCurrency(labeled[2] ?? labeled[1]) };
|
||||
// Fallback: a standalone amount carrying a currency symbol on its own line (e.g. a voucher's
|
||||
// "¥9,400") — the price sits far from any label the pattern above can anchor to.
|
||||
const symbol = text.match(/^\s*([€$£¥]\s?\d[\d.,]*)\b/m);
|
||||
if (symbol) return { price: strip(symbol[1]), currency: normCurrency(symbol[1]) };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schicht 2 — fill the booking-wide fields the per-reservation model call doesn't reliably
|
||||
* carry: the confirmation/PNR and the booking total + its currency. The confirmation and a
|
||||
* missing price are filled from the document; the currency is taken from the document's own
|
||||
* symbol/code (authoritative — small models misread it), correcting the model where needed.
|
||||
*/
|
||||
function fillBookingWideFields(flats: 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 handle it once (the first item).
|
||||
if (i === 0 && total) {
|
||||
if (priceMissing(f.price)) f.price = total.price;
|
||||
// The document's own currency symbol/code is authoritative; let it override the
|
||||
// model's guess (small models misread "¥" as "$").
|
||||
if (total.currency) f.currency = total.currency;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the router on extracted document text and return schema.org KiReservation nodes.
|
||||
* Returns `[]` (never throws for content reasons) so the caller degrades gracefully.
|
||||
*/
|
||||
export async function routeExtraction(text: string, ctx: RouterContext): Promise<{ kiItems: KiReservation[]; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 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, text);
|
||||
|
||||
const kiItems = nuExtractToKiReservations(flats as unknown as Record<string, unknown>[]) as unknown as KiReservation[];
|
||||
return { kiItems, warnings };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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'];
|
||||
|
||||
/** A flat reservation as the model emits it, before mapping to schema.org. The named fields
|
||||
* are the ones the router reads directly; the index signature carries the rest unchanged. */
|
||||
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;
|
||||
}
|
||||
|
||||
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'],
|
||||
// `operator` (rental company) is REQUIRED so the booking gets a real title instead of the
|
||||
// generic "Rental Car" fallback.
|
||||
['operator', 'from_name', 'departure_time', 'arrival_time'],
|
||||
),
|
||||
hotel: flat(
|
||||
['name', 'booking_reference', 'address', 'checkin_time', 'checkout_time', 'telephone', 'website', 'price', 'currency'],
|
||||
// `address` is REQUIRED so the model actually emits the (often unlabeled) street address line
|
||||
// — without it small models skip it and the booking loses its location/place.
|
||||
['name', 'address', '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,95 @@
|
||||
/**
|
||||
* 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,
|
||||
// Disable "thinking" for hybrid/reasoning models (Qwen3, etc.): the reasoning tokens
|
||||
// collide with the format-grammar constraint here — they produce unparseable output and
|
||||
// blow the latency budget on CPU. Ollama ignores this for non-thinking models, so it's safe.
|
||||
think: false,
|
||||
// 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,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,
|
||||
|
||||
@@ -64,8 +64,8 @@ export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
|
||||
toCode: airportCode(raw.to),
|
||||
toName: raw.to?.name ?? null,
|
||||
date: raw.date ?? null,
|
||||
departure: raw.departureScheduled ?? null,
|
||||
arrival: raw.arrivalScheduled ?? null,
|
||||
departure: raw.departureScheduled ?? raw.departure ?? null,
|
||||
arrival: raw.arrivalScheduled ?? raw.arrival ?? null,
|
||||
airline: entityName(raw.airline),
|
||||
flightNumber: raw.flightNumber ?? null,
|
||||
aircraft: entityCode(raw.aircraft),
|
||||
@@ -103,11 +103,14 @@ function hasCoords(a: AirtrailAirport | null): a is AirtrailAirport & { lat: num
|
||||
|
||||
/** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */
|
||||
export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation {
|
||||
// Read the SCHEDULED times only — TREK plans against the scheduled (booked) time,
|
||||
// not the actual/estimated `departure`/`arrival`. When a flight has no scheduled
|
||||
// time, the clock is left blank (date preserved) rather than fabricated.
|
||||
const dep = localParts(raw.departureScheduled, raw.from?.tz ?? null);
|
||||
const arr = localParts(raw.arrivalScheduled, raw.to?.tz ?? null);
|
||||
// Prefer the scheduled (booked) time TREK plans against, but fall back to the
|
||||
// primary departure/arrival instant when AirTrail has no scheduled time. Manually
|
||||
// entered flights only set `departure`/`arrival` (the `*Scheduled` columns stay
|
||||
// null), so reading scheduled alone dropped the clock — and the whole arrival —
|
||||
// for the common case (#1336). Only when neither exists is the clock left blank
|
||||
// (date preserved) rather than fabricated.
|
||||
const dep = localParts(raw.departureScheduled ?? raw.departure, raw.from?.tz ?? null);
|
||||
const arr = localParts(raw.arrivalScheduled ?? raw.arrival, raw.to?.tz ?? null);
|
||||
|
||||
const fromCode = airportCode(raw.from);
|
||||
const toCode = airportCode(raw.to);
|
||||
@@ -194,8 +197,11 @@ export function canonicalHash(raw: AirtrailFlightRaw): string {
|
||||
to: airportCode(raw.to),
|
||||
date: raw.date ?? null,
|
||||
datePrecision: raw.datePrecision ?? 'day',
|
||||
departureScheduled: raw.departureScheduled ?? null,
|
||||
arrivalScheduled: raw.arrivalScheduled ?? null,
|
||||
// Hash the same instant the import uses (scheduled, else primary) so a change to
|
||||
// whichever time TREK actually shows triggers a re-sync — and existing flights
|
||||
// imported without a scheduled time re-sync once to pick up their clock (#1336).
|
||||
departureScheduled: raw.departureScheduled ?? raw.departure ?? null,
|
||||
arrivalScheduled: raw.arrivalScheduled ?? raw.arrival ?? null,
|
||||
airline: entityCode(raw.airline),
|
||||
flightNumber: raw.flightNumber ?? null,
|
||||
aircraft: entityCode(raw.aircraft),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -49,14 +49,24 @@ function loadEndpoints(reservationId: number): ReservationEndpoint[] {
|
||||
function resolveDayIdFromTime(
|
||||
tripId: string | number,
|
||||
time: string | null | undefined,
|
||||
clampToNearest = true,
|
||||
): number | null {
|
||||
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 an imported booking whose
|
||||
// exact date has no day row (or sits just outside the span) still lands on a day.
|
||||
// Skipped by callers (e.g. resyncReservationDays) that must leave a booking whose
|
||||
// date now falls outside the range untouched instead of snapping it to an edge day.
|
||||
if (!clampToNearest) return null;
|
||||
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;
|
||||
}
|
||||
|
||||
// After a trip's date range changes, generateDays positionally re-dates the day rows
|
||||
@@ -76,10 +86,10 @@ export function resyncReservationDays(tripId: string | number): void {
|
||||
}[];
|
||||
const update = db.prepare('UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?');
|
||||
for (const r of rows) {
|
||||
const newDayId = resolveDayIdFromTime(tripId, r.reservation_time);
|
||||
const newDayId = resolveDayIdFromTime(tripId, r.reservation_time, false);
|
||||
if (newDayId == null) continue;
|
||||
const newEndDayId = r.reservation_end_time
|
||||
? (resolveDayIdFromTime(tripId, r.reservation_end_time) ?? r.end_day_id)
|
||||
? (resolveDayIdFromTime(tripId, r.reservation_end_time, false) ?? r.end_day_id)
|
||||
: r.end_day_id;
|
||||
if (newDayId !== r.day_id || newEndDayId !== r.end_day_id) {
|
||||
update.run(newDayId, newEndDayId, r.id);
|
||||
@@ -99,9 +109,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',
|
||||
@@ -24,6 +24,13 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'maplibre_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];
|
||||
@@ -34,9 +41,10 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
|
||||
time_format: ['12h', '24h'],
|
||||
dark_mode: [true, false, 'light', 'dark', 'auto'],
|
||||
map_provider: ['leaflet', 'mapbox-gl', 'maplibre-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; }
|
||||
@@ -157,3 +165,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,101 @@
|
||||
/**
|
||||
* First-run admin seeding (seedAdminAccount).
|
||||
*
|
||||
* Covers the #1339 fix: ADMIN_EMAIL/ADMIN_PASSWORD only take effect on first run
|
||||
* (empty database). Setting them once a user exists must no longer be silent — it
|
||||
* has to warn — and a partial config (only one of the two) must warn too instead
|
||||
* of quietly falling back to a generated password.
|
||||
*/
|
||||
import { seedAdminAccount } from '../../../src/db/seeds';
|
||||
import { createTestDb } from '../../helpers/test-db';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
const ENV_KEYS = ['ADMIN_EMAIL', 'ADMIN_PASSWORD', 'DEMO_MODE', 'OIDC_ONLY', 'OIDC_ISSUER', 'OIDC_CLIENT_ID'];
|
||||
|
||||
function countUsers(db: Database.Database): number {
|
||||
return (db.prepare('SELECT COUNT(*) as c FROM users').get() as { c: number }).c;
|
||||
}
|
||||
|
||||
function insertExistingUser(db: Database.Database): void {
|
||||
db.prepare(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES ('admin', 'admin@trek.local', 'x', 'admin')",
|
||||
).run();
|
||||
}
|
||||
|
||||
describe('seedAdminAccount — first-run admin', () => {
|
||||
let db: Database.Database;
|
||||
let saved: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
saved = {};
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k];
|
||||
delete process.env[k];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
for (const k of ENV_KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates the admin from ADMIN_EMAIL/ADMIN_PASSWORD on an empty database', () => {
|
||||
process.env.ADMIN_EMAIL = 'me@example.com';
|
||||
process.env.ADMIN_PASSWORD = 'S3cret-pw';
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
seedAdminAccount(db);
|
||||
|
||||
const user = db
|
||||
.prepare('SELECT email, role, must_change_password FROM users WHERE email = ?')
|
||||
.get('me@example.com') as { email: string; role: string; must_change_password: number } | undefined;
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.role).toBe('admin');
|
||||
expect(user!.must_change_password).toBe(1);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and creates nothing when ADMIN_* is set but a user already exists', () => {
|
||||
insertExistingUser(db);
|
||||
process.env.ADMIN_EMAIL = 'new@example.com';
|
||||
process.env.ADMIN_PASSWORD = 'whatever';
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
seedAdminAccount(db);
|
||||
|
||||
expect(countUsers(db)).toBe(1);
|
||||
expect(db.prepare('SELECT 1 FROM users WHERE email = ?').get('new@example.com')).toBeUndefined();
|
||||
const msg = warn.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
expect(msg).toContain('only apply on first run');
|
||||
});
|
||||
|
||||
it('stays silent when no admin env is set and a user already exists', () => {
|
||||
insertExistingUser(db);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
seedAdminAccount(db);
|
||||
|
||||
expect(countUsers(db)).toBe(1);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns about a partial config and falls back to a generated password', () => {
|
||||
process.env.ADMIN_EMAIL = 'me@example.com'; // ADMIN_PASSWORD intentionally missing
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
seedAdminAccount(db);
|
||||
|
||||
// Falls back to the default local admin, NOT the provided email.
|
||||
expect(db.prepare('SELECT 1 FROM users WHERE email = ?').get('admin@trek.local')).toBeDefined();
|
||||
expect(db.prepare('SELECT 1 FROM users WHERE email = ?').get('me@example.com')).toBeUndefined();
|
||||
const msg = warn.mock.calls.map((c) => c.join(' ')).join('\n');
|
||||
expect(msg).toContain('Only one of ADMIN_EMAIL/ADMIN_PASSWORD');
|
||||
});
|
||||
});
|
||||
@@ -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,75 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { broadcastToUser } = vi.hoisted(() => ({ broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../../../src/websocket', () => ({ broadcastToUser }));
|
||||
|
||||
import { ImportJobsService } from '../../../../src/nest/booking-import/import-jobs.service';
|
||||
|
||||
type Preview = ReturnType<typeof vi.fn>;
|
||||
function makeService(preview: Preview) {
|
||||
return new ImportJobsService({ preview } as never);
|
||||
}
|
||||
const files = (n: number) => Array.from({ length: n }, (_, i) => ({ originalname: `f${i}.pdf` })) as never;
|
||||
const eventsFor = (jobId: string) => broadcastToUser.mock.calls.map((c) => c[1]).filter((p) => p.jobId === jobId);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('ImportJobsService', () => {
|
||||
it('runs the parse off-request, reports progress and pushes the result on done', async () => {
|
||||
const preview = vi.fn(async (_f, _m, _u, onProgress: (d: number, t: number, name?: string) => void) => {
|
||||
onProgress(1, 2, 'f0.pdf');
|
||||
return { items: [{ id: 'x' }] };
|
||||
});
|
||||
const svc = makeService(preview);
|
||||
|
||||
const id = svc.start('7', files(2), 'fallback-on-empty', 42);
|
||||
expect(typeof id).toBe('string');
|
||||
|
||||
await vi.waitFor(() => expect(svc.get(id, 42)?.status).toBe('done'));
|
||||
const job = svc.get(id, 42)!;
|
||||
expect(job.result).toEqual({ items: [{ id: 'x' }] });
|
||||
expect(job.done).toBe(1);
|
||||
expect(preview).toHaveBeenCalledWith(expect.anything(), 'fallback-on-empty', 42, expect.any(Function));
|
||||
|
||||
const types = eventsFor(id).map((p) => p.type);
|
||||
expect(types).toContain('import:progress');
|
||||
expect(types).toContain('import:done');
|
||||
expect(eventsFor(id).every((p) => p.tripId === '7')).toBe(true);
|
||||
});
|
||||
|
||||
it('records an error and pushes import:error when the parse throws', async () => {
|
||||
const preview = vi.fn(async () => { throw new Error('parse boom'); });
|
||||
const svc = makeService(preview);
|
||||
|
||||
const id = svc.start('1', files(1), 'no-ai', 9);
|
||||
await vi.waitFor(() => expect(svc.get(id, 9)?.status).toBe('error'));
|
||||
expect(svc.get(id, 9)!.error).toBe('parse boom');
|
||||
expect(eventsFor(id).map((p) => p.type)).toContain('import:error');
|
||||
});
|
||||
|
||||
it('only returns a job to its owner', async () => {
|
||||
const svc = makeService(vi.fn(async () => ({ items: [] })));
|
||||
const id = svc.start('1', files(1), 'no-ai', 9);
|
||||
expect(svc.get(id, 9)).toBeDefined();
|
||||
expect(svc.get(id, 999)).toBeUndefined();
|
||||
expect(svc.get('does-not-exist', 9)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('chains a user\'s parses so they run one at a time', async () => {
|
||||
const order: string[] = [];
|
||||
const preview = vi.fn(async (f: { originalname: string }[]) => {
|
||||
order.push(`start:${f[0].originalname}`);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
order.push(`end:${f[0].originalname}`);
|
||||
return { items: [] };
|
||||
});
|
||||
const svc = makeService(preview);
|
||||
|
||||
const a = svc.start('1', [{ originalname: 'A.pdf' }] as never, 'no-ai', 5);
|
||||
const b = svc.start('1', [{ originalname: 'B.pdf' }] as never, 'no-ai', 5);
|
||||
await vi.waitFor(() => expect(svc.get(b, 5)?.status).toBe('done'));
|
||||
expect(svc.get(a, 5)?.status).toBe('done');
|
||||
// B must not start before A finished — the per-user chain serializes them.
|
||||
expect(order).toEqual(['start:A.pdf', 'end:A.pdf', 'start:B.pdf', 'end:B.pdf']);
|
||||
});
|
||||
});
|
||||
@@ -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,168 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// The router's single model call and the schema.org mapper are mocked: we drive the
|
||||
// enforced-extract output directly and inspect the flat reservations handed to the mapper,
|
||||
// so these tests cover the router's orchestration and deterministic post-processing without
|
||||
// a live Ollama or the real mapper.
|
||||
const { extractEnforced, mapToKi } = vi.hoisted(() => ({ extractEnforced: vi.fn(), mapToKi: vi.fn() }));
|
||||
vi.mock('../../../../src/nest/llm-parse/router/ollama-format.client', () => ({ extractEnforced }));
|
||||
vi.mock('../../../../src/nest/llm-parse/clients/nuextract', () => ({ nuExtractToKiReservations: mapToKi }));
|
||||
|
||||
import {
|
||||
extractBookingRef,
|
||||
extractTotalPrice,
|
||||
normCurrency,
|
||||
detectFlightNumbers,
|
||||
fixArrivalDate,
|
||||
routeExtraction,
|
||||
} from '../../../../src/nest/llm-parse/router/extraction-router';
|
||||
|
||||
const CTX = { baseUrl: 'http://ollama:11434/v1', model: 'qwen3:8b' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mapToKi.mockReturnValue([{ '@type': 'Mock' }]);
|
||||
});
|
||||
|
||||
describe('extractBookingRef', () => {
|
||||
it('reads an Airbnb "Bestätigungs-Code"', () => {
|
||||
expect(extractBookingRef('Bestätigungs-Code\nHMHJ9RTEEK')).toBe('HMHJ9RTEEK');
|
||||
});
|
||||
it('prefers the customer "Reservation No." over a later "Supplier Reference"', () => {
|
||||
expect(extractBookingRef('Reservation No.: G72820729\nSUPPLIER DETAILS\nSupplier Reference: IT587200464')).toBe('G72820729');
|
||||
});
|
||||
it('reads an Expedia "Reiseplan" number', () => {
|
||||
expect(extractBookingRef('Expedia-Reiseplan: 73222406755286')).toBe('73222406755286');
|
||||
});
|
||||
it('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 a labeled German total', () => {
|
||||
expect(extractTotalPrice('Gesamtpreis 61,23 €')).toEqual({ price: '61,23', currency: 'EUR' });
|
||||
});
|
||||
it('reads an Airbnb "Bezahlter Betrag"', () => {
|
||||
expect(extractTotalPrice('Bezahlter Betrag\n651,86 €')).toEqual({ price: '651,86', currency: 'EUR' });
|
||||
});
|
||||
it('falls back to a standalone ¥ voucher price (JPY) with no nearby label', () => {
|
||||
expect(extractTotalPrice('Price (consumption tax included)\n金額(消費税込)\n¥9,400\nAdult')).toEqual({ price: '9,400', currency: 'JPY' });
|
||||
});
|
||||
it('returns null when there is neither a labeled nor a symbol amount', () => {
|
||||
expect(extractTotalPrice('Just some terms and conditions, no price here.')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normCurrency', () => {
|
||||
it('maps symbols and codes to ISO 4217', () => {
|
||||
expect(normCurrency('€')).toBe('EUR');
|
||||
expect(normCurrency('¥')).toBe('JPY');
|
||||
expect(normCurrency('$')).toBe('USD');
|
||||
expect(normCurrency('£')).toBe('GBP');
|
||||
expect(normCurrency('CHF')).toBe('CHF');
|
||||
});
|
||||
it('returns undefined for an unrecognised token', () => {
|
||||
expect(normCurrency('')).toBeUndefined();
|
||||
expect(normCurrency('hello world')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectFlightNumbers', () => {
|
||||
it('finds flight numbers order-preserving and deduped', () => {
|
||||
expect(detectFlightNumbers('Flug LH 400, dann LH400 und BA1234')).toEqual(['LH400', 'BA1234']);
|
||||
});
|
||||
it('returns [] when there is no flight-number pattern', () => {
|
||||
expect(detectFlightNumbers('A hotel booking with no flight codes')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixArrivalDate', () => {
|
||||
it('keeps the same day when arrival is later than departure', () => {
|
||||
const out = fixArrivalDate({ type: 'flight', departure_time: '2025-08-23T10:00', arrival_time: '13:00' });
|
||||
expect(out.arrival_time).toBe('2025-08-23T13:00:00');
|
||||
});
|
||||
it('rolls to the next day for an overnight leg', () => {
|
||||
const out = fixArrivalDate({ type: 'flight', departure_time: '2025-08-30T18:00', arrival_time: '07:00' });
|
||||
expect(out.arrival_time).toBe('2025-08-31T07:00:00');
|
||||
});
|
||||
it('leaves a non-transport reservation untouched', () => {
|
||||
const hotel = { type: 'hotel' as const, arrival_time: '07:00' };
|
||||
expect(fixArrivalDate(hotel).arrival_time).toBe('07:00');
|
||||
});
|
||||
it('leaves it untouched when departure or arrival is missing', () => {
|
||||
expect(fixArrivalDate({ type: 'flight' }).arrival_time).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeExtraction', () => {
|
||||
it('extracts every flight leg in one call and normalizes/rolls arrival dates', async () => {
|
||||
extractEnforced.mockResolvedValue({
|
||||
flights: [
|
||||
{ vehicle_number: 'LH400', from_code: 'FRA', to_code: 'JFK', departure_time: 'Aug 23 2025 10:00', arrival_time: '13:00' },
|
||||
{ vehicle_number: 'LH401', from_code: 'JFK', to_code: 'FRA', departure_time: '2025-08-30T18:00', arrival_time: '07:00' },
|
||||
],
|
||||
});
|
||||
const res = await routeExtraction('Flug LH 400 hin und zurück', CTX);
|
||||
expect(extractEnforced).toHaveBeenCalledTimes(1);
|
||||
expect(res.warnings).toEqual([]);
|
||||
expect(res.kiItems).toEqual([{ '@type': 'Mock' }]);
|
||||
const flats = mapToKi.mock.calls[0][0];
|
||||
expect(flats).toHaveLength(2);
|
||||
expect(flats[0].departure_time).toMatch(/^2025-08-23T\d{2}:\d{2}:00$/); // natural-language → ISO
|
||||
expect(flats[1].arrival_time).toBe('2025-08-31T07:00:00'); // overnight roll (TZ-safe: derived from the ISO departure date)
|
||||
});
|
||||
|
||||
it('extracts a single reservation with the type-specific schema when keywords give the type away', async () => {
|
||||
extractEnforced.mockResolvedValue({ name: 'B&B Hotel', address: 'Str 1', checkin_time: '2025-05-01', checkout_time: '2025-05-02' });
|
||||
const res = await routeExtraction('Hotel booking — check-in 1 May', CTX);
|
||||
expect(res.warnings).toEqual([]);
|
||||
const flats = mapToKi.mock.calls[0][0];
|
||||
expect(flats).toHaveLength(1);
|
||||
expect(flats[0].type).toBe('hotel');
|
||||
});
|
||||
|
||||
it('falls back to the union schema and the model-picked type for an unclear document', async () => {
|
||||
extractEnforced.mockResolvedValue({ type: 'event', name: 'Concert' });
|
||||
const res = await routeExtraction('A document with no obvious type keywords', CTX);
|
||||
const flats = mapToKi.mock.calls[0][0];
|
||||
expect(flats[0].type).toBe('event');
|
||||
expect(res.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults the union type to hotel when the model omits it', async () => {
|
||||
extractEnforced.mockResolvedValue({});
|
||||
await routeExtraction('No keywords and no type field present', CTX);
|
||||
expect(mapToKi.mock.calls[0][0][0].type).toBe('hotel');
|
||||
});
|
||||
|
||||
it('fills the booking reference and total price deterministically from the text', async () => {
|
||||
extractEnforced.mockResolvedValue({ name: 'B&B Hotel', checkin_time: '2025-05-01', checkout_time: '2025-05-02' });
|
||||
await routeExtraction('Hotel check-in\nBuchungsnummer: ABC123\nGesamtpreis 99,00 €', CTX);
|
||||
const flat = mapToKi.mock.calls[0][0][0];
|
||||
expect(flat.booking_reference).toBe('ABC123');
|
||||
expect(flat.price).toBe('99,00');
|
||||
expect(flat.currency).toBe('EUR');
|
||||
});
|
||||
|
||||
it("lets the document's currency override the model but keeps a price the model already found", async () => {
|
||||
extractEnforced.mockResolvedValue({ name: 'B&B Hotel', checkin_time: '2025-05-01', checkout_time: '2025-05-02', price: '50', currency: 'USD' });
|
||||
await routeExtraction('Hotel check-in\nGesamtpreis 99,00 €', CTX);
|
||||
const flat = mapToKi.mock.calls[0][0][0];
|
||||
expect(flat.currency).toBe('EUR'); // document symbol wins over the model guess
|
||||
expect(flat.price).toBe('50'); // a non-empty model price is kept
|
||||
});
|
||||
|
||||
it('returns a warning (and no items) when the model call throws', async () => {
|
||||
extractEnforced.mockRejectedValue(new Error('connection refused'));
|
||||
const res = await routeExtraction('Hotel check-in', CTX);
|
||||
expect(res.kiItems).toEqual([]);
|
||||
expect(res.warnings[0]).toContain('AI parsing failed');
|
||||
expect(res.warnings[0]).toContain('connection refused');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createLlmClient } from '../../../../src/nest/llm-parse/llm-client.factory';
|
||||
import { OpenAiCompatibleClient } from '../../../../src/nest/llm-parse/clients/openai-compatible.client';
|
||||
import { AnthropicClient } from '../../../../src/nest/llm-parse/clients/anthropic.client';
|
||||
import type { ResolvedLlmConfig } from '../../../../src/services/llmConfig';
|
||||
|
||||
const cfg = (provider: string): ResolvedLlmConfig =>
|
||||
({ provider, model: 'm', baseUrl: 'http://x', multimodal: false } as unknown as ResolvedLlmConfig);
|
||||
|
||||
describe('createLlmClient', () => {
|
||||
it('returns the Anthropic client for the anthropic provider', () => {
|
||||
expect(createLlmClient(cfg('anthropic'))).toBeInstanceOf(AnthropicClient);
|
||||
});
|
||||
|
||||
it('returns the OpenAI-compatible client for openai and local', () => {
|
||||
expect(createLlmClient(cfg('openai'))).toBeInstanceOf(OpenAiCompatibleClient);
|
||||
expect(createLlmClient(cfg('local'))).toBeInstanceOf(OpenAiCompatibleClient);
|
||||
});
|
||||
|
||||
it('falls back to the OpenAI-compatible client for an unknown provider', () => {
|
||||
expect(createLlmClient(cfg('something-else'))).toBeInstanceOf(OpenAiCompatibleClient);
|
||||
});
|
||||
});
|
||||
@@ -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,169 @@
|
||||
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 };
|
||||
});
|
||||
|
||||
const { routeExtraction, detectFlightNumbers } = vi.hoisted(() => ({
|
||||
routeExtraction: vi.fn(),
|
||||
detectFlightNumbers: vi.fn(() => [] as string[]),
|
||||
}));
|
||||
vi.mock('../../../../src/nest/llm-parse/router/extraction-router', () => ({ routeExtraction, detectFlightNumbers }));
|
||||
|
||||
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');
|
||||
detectFlightNumbers.mockReturnValue([]);
|
||||
routeExtraction.mockResolvedValue({ kiItems: [{ '@type': 'LodgingReservation' }], warnings: [] });
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('routes the local provider through the extraction router instead of the single-shot client', async () => {
|
||||
resolveLlmConfig.mockReturnValue(cfg({ provider: 'local', baseUrl: 'http://ollama:11434/v1', apiKey: 'k' }));
|
||||
extractText.mockResolvedValue('Hotel booking');
|
||||
routeExtraction.mockResolvedValue({ kiItems: [{ '@type': 'LodgingReservation' }], warnings: ['note'] });
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
expect(res.kiItems).toEqual([{ '@type': 'LodgingReservation' }]);
|
||||
expect(res.warnings).toEqual(['note']);
|
||||
expect(extract).not.toHaveBeenCalled();
|
||||
expect(routeExtraction).toHaveBeenCalledWith('Hotel booking', { baseUrl: 'http://ollama:11434/v1', model: 'm', apiKey: 'k' });
|
||||
});
|
||||
|
||||
it('keeps the wide text cap (16k) for a local flight itinerary but tightens it (6k) otherwise', async () => {
|
||||
const long = 'x'.repeat(7000);
|
||||
extractText.mockResolvedValue(long);
|
||||
|
||||
resolveLlmConfig.mockReturnValue(cfg({ provider: 'local' }));
|
||||
detectFlightNumbers.mockReturnValue(['AB123']);
|
||||
await svc().parse(file('flights.txt'), 1);
|
||||
expect(routeExtraction.mock.calls[0][0]).toHaveLength(7000); // under the 16k cap, untouched
|
||||
|
||||
vi.clearAllMocks();
|
||||
resolveLlmConfig.mockReturnValue(cfg({ provider: 'local' }));
|
||||
extractText.mockResolvedValue(long);
|
||||
detectFlightNumbers.mockReturnValue([]);
|
||||
routeExtraction.mockResolvedValue({ kiItems: [], warnings: [] });
|
||||
await svc().parse(file('hotel.txt'), 1);
|
||||
expect(routeExtraction.mock.calls[0][0]).toHaveLength(6000); // single booking → tighter cap
|
||||
});
|
||||
|
||||
it('degrades to a warning when the local router throws', async () => {
|
||||
resolveLlmConfig.mockReturnValue(cfg({ provider: 'local' }));
|
||||
routeExtraction.mockRejectedValue(new Error('ollama down'));
|
||||
const res = await svc().parse(file('a.txt'), 1);
|
||||
expect(res.kiItems).toEqual([]);
|
||||
expect(res.warnings[0]).toMatch(/AI parsing failed/i);
|
||||
});
|
||||
|
||||
it('warns when the file cannot be read (text extraction throws)', async () => {
|
||||
extractText.mockRejectedValue(new Error('corrupt pdf'));
|
||||
const res = await svc().parse(file('a.pdf', '%PDF'), 1);
|
||||
expect(res.kiItems).toEqual([]);
|
||||
expect(res.warnings[0]).toMatch(/could not read file/i);
|
||||
expect(res.warnings[0]).toContain('corrupt pdf');
|
||||
});
|
||||
});
|
||||
@@ -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,227 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nuExtractToKiReservations — remaining reservation types', () => {
|
||||
const one = (x: Record<string, unknown>) => nuExtractToKiReservations(x)[0];
|
||||
|
||||
it('maps a train into a TrainReservation with stations', () => {
|
||||
const node = one({ type: 'train', vehicle_number: 'ICE 597', from_name: 'Berlin Hbf', to_name: 'München Hbf', departure_time: '2025-05-01T08:00:00' });
|
||||
expect(node['@type']).toBe('TrainReservation');
|
||||
expect(node.reservationFor).toMatchObject({ trainNumber: 'ICE 597', departureStation: { name: 'Berlin Hbf' }, arrivalStation: { name: 'München Hbf' } });
|
||||
});
|
||||
|
||||
it('maps a bus into a BusReservation with stops', () => {
|
||||
const node = one({ type: 'bus', vehicle_number: 'FB42', from_name: 'Köln', to_name: 'Paris' });
|
||||
expect(node['@type']).toBe('BusReservation');
|
||||
expect(node.reservationFor).toMatchObject({ busNumber: 'FB42', departureBusStop: { name: 'Köln' }, arrivalBusStop: { name: 'Paris' } });
|
||||
});
|
||||
|
||||
it('maps a ferry into a BoatReservation, using the operator when no name is given', () => {
|
||||
const node = one({ type: 'ferry', operator: 'Stena Line', from_name: 'Kiel', to_name: 'Göteborg' });
|
||||
expect(node['@type']).toBe('BoatReservation');
|
||||
expect((node.reservationFor as Record<string, unknown>).name).toBe('Stena Line');
|
||||
});
|
||||
|
||||
it('maps a restaurant into a FoodEstablishmentReservation', () => {
|
||||
const node = one({ type: 'restaurant', name: 'Osteria', address: 'Via Roma 1', start_time: '2025-05-01T19:30:00' });
|
||||
expect(node['@type']).toBe('FoodEstablishmentReservation');
|
||||
expect(node.startTime).toBe('2025-05-01T19:30:00');
|
||||
expect((node.reservationFor as Record<string, unknown>).name).toBe('Osteria');
|
||||
});
|
||||
|
||||
it('maps an event into an EventReservation with a location', () => {
|
||||
const node = one({ type: 'event', name: 'Concert', address: 'Arena', start_time: '2025-05-01T20:00:00', end_time: '2025-05-01T23:00:00' });
|
||||
expect(node['@type']).toBe('EventReservation');
|
||||
expect(node.startTime).toBe('2025-05-01T20:00:00');
|
||||
expect(node.reservationFor).toMatchObject({ name: 'Concert', location: { address: 'Arena' } });
|
||||
});
|
||||
|
||||
it('uses the generic name fallback for a nameless restaurant/event with no address', () => {
|
||||
expect((one({ type: 'restaurant', start_time: '2025-05-01T19:30:00' }).reservationFor as Record<string, unknown>).name).toBe('Restaurant');
|
||||
expect((one({ type: 'event', start_time: '2025-05-01T20:00:00' }).reservationFor as Record<string, unknown>).name).toBe('Event');
|
||||
});
|
||||
|
||||
it('resolves GBP, JPY and a bare ISO code, and leaves an unrecognised currency undefined', () => {
|
||||
expect(one({ type: 'hotel', name: 'A', price: '£120.00' }).priceCurrency).toBe('GBP');
|
||||
expect(one({ type: 'event', name: 'B', price: '¥9,400' }).priceCurrency).toBe('JPY');
|
||||
expect(one({ type: 'hotel', name: 'C', currency: 'CHF', price: '200' }).priceCurrency).toBe('CHF');
|
||||
expect(one({ type: 'hotel', name: 'D', price: '200' }).priceCurrency).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses a plain number price, grouping without a decimal, and drops an unparseable amount', () => {
|
||||
expect(one({ type: 'hotel', name: 'A', price: 89 }).price).toBe(89);
|
||||
expect(one({ type: 'hotel', name: 'B', price: '1.580' }).price).toBe(1580); // dot is grouping, not a decimal
|
||||
expect(one({ type: 'hotel', name: 'C', price: 'free of charge' }).price).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a bare array of reservations', () => {
|
||||
const out = nuExtractToKiReservations([{ type: 'hotel', name: 'A' }, { type: 'train', from_name: 'X', to_name: 'Y' }]);
|
||||
expect(out.map((n) => n['@type'])).toEqual(['LodgingReservation', 'TrainReservation']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { toNativeBase, extractEnforced } from '../../../../src/nest/llm-parse/router/ollama-format.client';
|
||||
|
||||
function mockFetch(impl: (url: string, init: RequestInit) => Promise<Response> | Response) {
|
||||
const fn = vi.fn(impl as unknown as typeof fetch);
|
||||
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;
|
||||
}
|
||||
|
||||
const INPUT = {
|
||||
baseUrl: 'http://ollama:11434/v1',
|
||||
model: 'qwen3:8b',
|
||||
system: 'sys',
|
||||
user: 'doc',
|
||||
schema: { type: 'object' as const },
|
||||
};
|
||||
|
||||
beforeEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('toNativeBase', () => {
|
||||
it('strips a /v1 suffix and trailing slashes', () => {
|
||||
expect(toNativeBase('http://ollama:11434/v1')).toBe('http://ollama:11434');
|
||||
expect(toNativeBase('http://ollama:11434/v1/')).toBe('http://ollama:11434');
|
||||
expect(toNativeBase('http://ollama:11434/')).toBe('http://ollama:11434');
|
||||
expect(toNativeBase('http://ollama:11434')).toBe('http://ollama:11434');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractEnforced', () => {
|
||||
it('posts to the native /api/chat with the grammar format and thinking disabled', async () => {
|
||||
const fetchFn = mockFetch(() => jsonResponse({ message: { content: '{"name":"Hotel"}' } }));
|
||||
const out = await extractEnforced(INPUT);
|
||||
expect(out).toEqual({ name: 'Hotel' });
|
||||
const [url, init] = fetchFn.mock.calls[0];
|
||||
expect(url).toBe('http://ollama:11434/api/chat');
|
||||
const body = JSON.parse((init as RequestInit).body as string);
|
||||
expect(body.format).toEqual({ type: 'object' });
|
||||
expect(body.think).toBe(false);
|
||||
expect(body.stream).toBe(false);
|
||||
expect(body.options.temperature).toBe(0);
|
||||
expect((init as RequestInit).headers).not.toHaveProperty('authorization');
|
||||
});
|
||||
|
||||
it('sends a bearer header only when an apiKey is given', async () => {
|
||||
const fetchFn = mockFetch(() => jsonResponse({ message: { content: '{}' } }));
|
||||
await extractEnforced({ ...INPUT, apiKey: 'sk-123', numPredict: 900, numCtx: 16000 });
|
||||
const init = fetchFn.mock.calls[0][1] as RequestInit;
|
||||
expect((init.headers as Record<string, string>).authorization).toBe('Bearer sk-123');
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.options.num_predict).toBe(900);
|
||||
expect(body.options.num_ctx).toBe(16000);
|
||||
});
|
||||
|
||||
it('strips a ```json code fence before parsing', async () => {
|
||||
mockFetch(() => jsonResponse({ message: { content: '```json\n{"a":1}\n```' } }));
|
||||
expect(await extractEnforced(INPUT)).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('returns null when the content parses to a non-object', async () => {
|
||||
mockFetch(() => jsonResponse({ message: { content: '"just a string"' } }));
|
||||
expect(await extractEnforced(INPUT)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unparseable content', async () => {
|
||||
mockFetch(() => jsonResponse({ message: { content: 'not json at all' } }));
|
||||
expect(await extractEnforced(INPUT)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the response has no content', async () => {
|
||||
mockFetch(() => jsonResponse({ message: {} }));
|
||||
expect(await extractEnforced(INPUT)).toBeNull();
|
||||
});
|
||||
|
||||
it('throws with the status when Ollama responds non-ok', async () => {
|
||||
mockFetch(() => jsonResponse({ error: 'model not found' }, false, 404));
|
||||
await expect(extractEnforced(INPUT)).rejects.toThrow(/Ollama \/api\/chat failed \(404\)/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -51,11 +51,17 @@ describe('airtrailMapper.normalizeFlight', () => {
|
||||
flightNumber: 'BA178',
|
||||
seatClass: 'economy',
|
||||
});
|
||||
// The picker preview surfaces the scheduled times, not the actual ones.
|
||||
// The picker preview prefers the scheduled times over the actual ones.
|
||||
expect(n.departure).toBe('2021-09-01T23:00:00.000+00:00');
|
||||
expect(n.arrival).toBe('2021-09-02T07:00:00.000+00:00');
|
||||
});
|
||||
|
||||
it('#1336 surfaces the primary departure/arrival when there is no scheduled time', () => {
|
||||
const n = normalizeFlight(flight({ departureScheduled: null, arrivalScheduled: null }));
|
||||
expect(n.departure).toBe('2021-09-01T23:42:00.000+00:00');
|
||||
expect(n.arrival).toBe('2021-09-02T07:42:00.000+00:00');
|
||||
});
|
||||
|
||||
it('falls back to ICAO when IATA is missing and tolerates null airports', () => {
|
||||
const n = normalizeFlight(flight({ from: airport({ iata: null }), to: null }));
|
||||
expect(n.fromCode).toBe('KJFK');
|
||||
@@ -74,8 +80,8 @@ describe('airtrailMapper.mapFlightToReservation', () => {
|
||||
expect(m.reservation_end_time).toBe('2021-09-02T08:00');
|
||||
});
|
||||
|
||||
it('leaves the clock blank (date only) when the flight has no scheduled time', () => {
|
||||
const m = mapFlightToReservation(flight({ departureScheduled: null, arrivalScheduled: null }));
|
||||
it('leaves the clock blank (date only) when the flight has no time at all', () => {
|
||||
const m = mapFlightToReservation(flight({ departure: null, arrival: null, departureScheduled: null, arrivalScheduled: null }));
|
||||
// Date is preserved from the AirTrail canonical date; no fabricated 00:00.
|
||||
expect(m.reservation_time).toBe('2021-09-01');
|
||||
expect(m.reservation_end_time).toBeNull();
|
||||
@@ -83,6 +89,17 @@ describe('airtrailMapper.mapFlightToReservation', () => {
|
||||
expect(m.endpoints.find(e => e.role === 'to')?.local_time).toBeNull();
|
||||
});
|
||||
|
||||
it('#1336 falls back to the primary departure/arrival when AirTrail has no scheduled times', () => {
|
||||
// Manually-entered AirTrail flights set only departure/arrival; *Scheduled stays null.
|
||||
const m = mapFlightToReservation(flight({ departureScheduled: null, arrivalScheduled: null }));
|
||||
// departure 23:42 UTC at JFK (EDT) = 19:42 local; arrival 07:42 UTC at LHR (BST) = 08:42.
|
||||
expect(m.reservation_time).toBe('2021-09-01T19:42');
|
||||
expect(m.reservation_end_time).toBe('2021-09-02T08:42');
|
||||
expect(m.endpoints.find(e => e.role === 'from')?.local_time).toBe('19:42');
|
||||
expect(m.endpoints.find(e => e.role === 'to')?.local_time).toBe('08:42');
|
||||
expect(m.endpoints.find(e => e.role === 'to')?.local_date).toBe('2021-09-02');
|
||||
});
|
||||
|
||||
it('builds two endpoints with codes, coords and timezones', () => {
|
||||
const m = mapFlightToReservation(flight());
|
||||
expect(m.endpoints).toHaveLength(2);
|
||||
@@ -142,8 +159,8 @@ describe('airtrailMapper.mapFlightToReservation', () => {
|
||||
expect(m.endpoints.find(e => e.role === 'to')).toBeDefined();
|
||||
});
|
||||
|
||||
it('leaves the end time null for a partial flight with no scheduled arrival', () => {
|
||||
const m = mapFlightToReservation(flight({ arrivalScheduled: null }));
|
||||
it('leaves the end time null for a partial flight with no arrival time at all', () => {
|
||||
const m = mapFlightToReservation(flight({ arrival: null, arrivalScheduled: null }));
|
||||
expect(m.reservation_end_time).toBeNull();
|
||||
expect(m.reservation_time).toBe('2021-09-01T19:00');
|
||||
});
|
||||
@@ -164,12 +181,20 @@ describe('airtrailMapper.canonicalHash', () => {
|
||||
expect(canonicalHash(flight())).not.toBe(
|
||||
canonicalHash(flight({ departureScheduled: '2021-09-01T22:00:00.000+00:00' })),
|
||||
);
|
||||
// ...but a change to the actual time alone must not (TREK never shows it).
|
||||
// ...but a change to the actual time alone must not (TREK shows the scheduled one).
|
||||
expect(canonicalHash(flight())).toBe(
|
||||
canonicalHash(flight({ departure: '2021-09-01T20:00:00.000+00:00', arrival: '2021-09-02T05:00:00.000+00:00' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('#1336 tracks the primary departure when there is no scheduled time', () => {
|
||||
// With no scheduled time, departure IS what TREK imports, so a change must re-sync.
|
||||
const manual = flight({ departureScheduled: null, arrivalScheduled: null });
|
||||
expect(canonicalHash(manual)).not.toBe(
|
||||
canonicalHash(flight({ departureScheduled: null, arrivalScheduled: null, departure: '2021-09-01T20:00:00.000+00:00' })),
|
||||
);
|
||||
});
|
||||
|
||||
it('is independent of seat ordering', () => {
|
||||
const a = flight({
|
||||
seats: [
|
||||
|
||||
@@ -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} حجز/حجوزات',
|
||||
|
||||
@@ -59,6 +59,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':
|
||||
'عند تحسين يوم ما، يبدأ المسار من الفندق الذي تستيقظ فيه وينتهي عند الفندق الذي تسجّل الوصول إليه في تلك الليلة.',
|
||||
@@ -315,6 +317,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'اختبار الاتصال',
|
||||
'settings.airtrail.test.success': 'متصل — تم العثور على {count} رحلة/رحلات',
|
||||
'settings.airtrail.test.failed': 'فشل الاتصال',
|
||||
'settings.aiParsing.title': 'التحليل بالذكاء الاصطناعي',
|
||||
'settings.aiParsing.hint': 'استخدم نموذج الذكاء الاصطناعي الخاص بك لاستخراج الحجوزات من الملفات المرفوعة. لا يسري هذا إلا عندما لا يكون المسؤول قد أعدّ نموذجًا للنظام بأكمله.',
|
||||
'settings.aiParsing.provider': 'المزوّد',
|
||||
'settings.aiParsing.providerLocal': 'محلي (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'النموذج',
|
||||
'settings.aiParsing.baseUrl': 'عنوان URL الأساسي',
|
||||
'settings.aiParsing.baseUrlHint': 'المكان الذي يعمل فيه النموذج — خادم Ollama محلي أو نقطة نهاية متوافقة مع OpenAI.',
|
||||
'settings.aiParsing.apiKey': 'مفتاح API',
|
||||
'settings.aiParsing.apiKeyHint': 'يُخزَّن مشفّرًا. اتركه فارغًا للإبقاء على المفتاح الحالي.',
|
||||
'settings.aiParsing.multimodal': 'إرسال المستندات كصور',
|
||||
'settings.aiParsing.multimodalHint': 'للنماذج القادرة على الرؤية — يرسل ملف PDF الأصلي بدلًا من النص المستخرج.',
|
||||
'settings.aiParsing.toast.saved': 'تم حفظ إعدادات الذكاء الاصطناعي',
|
||||
'settings.aiParsing.toast.saveError': 'تعذّر حفظ إعدادات الذكاء الاصطناعي',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -60,6 +60,8 @@ const settings: TranslationStrings = {
|
||||
'settings.distance': 'Unidade de distância',
|
||||
'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.',
|
||||
@@ -325,6 +327,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'Testar conexão',
|
||||
'settings.airtrail.test.success': 'Conectado — {count} voo(s) encontrado(s)',
|
||||
'settings.airtrail.test.failed': 'Falha na conexão',
|
||||
'settings.aiParsing.title': 'Análise por IA',
|
||||
'settings.aiParsing.hint': 'Use seu próprio modelo de IA para extrair reservas dos arquivos enviados. Isso se aplica apenas quando o administrador não configurou um modelo para toda a instância.',
|
||||
'settings.aiParsing.provider': 'Provedor',
|
||||
'settings.aiParsing.providerLocal': 'Local (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'Modelo',
|
||||
'settings.aiParsing.baseUrl': 'URL base',
|
||||
'settings.aiParsing.baseUrlHint': 'Onde o modelo é executado — um servidor Ollama local ou um endpoint compatível com OpenAI.',
|
||||
'settings.aiParsing.apiKey': 'Chave de API',
|
||||
'settings.aiParsing.apiKeyHint': 'Armazenada de forma criptografada. Deixe em branco para manter a chave atual.',
|
||||
'settings.aiParsing.multimodal': 'Enviar documentos como imagens',
|
||||
'settings.aiParsing.multimodalHint': 'Para modelos com capacidade de visão — envia o PDF original em vez do texto extraído.',
|
||||
'settings.aiParsing.toast.saved': 'Configurações de IA salvas',
|
||||
'settings.aiParsing.toast.saveError': 'Não foi possível salvar as configurações de IA',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -59,6 +59,8 @@ const settings: TranslationStrings = {
|
||||
'settings.distance': 'Jednotky vzdálenosti',
|
||||
'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.',
|
||||
@@ -322,6 +324,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'Otestovat připojení',
|
||||
'settings.airtrail.test.success': 'Připojeno – nalezeno letů: {count}',
|
||||
'settings.airtrail.test.failed': 'Připojení selhalo',
|
||||
'settings.aiParsing.title': 'Zpracování pomocí AI',
|
||||
'settings.aiParsing.hint': 'Použijte vlastní model AI k získání rezervací z nahraných souborů. Platí pouze tehdy, když správce nenastavil model pro celou instanci.',
|
||||
'settings.aiParsing.provider': 'Poskytovatel',
|
||||
'settings.aiParsing.providerLocal': 'Místní (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'Model',
|
||||
'settings.aiParsing.baseUrl': 'Základní URL',
|
||||
'settings.aiParsing.baseUrlHint': 'Kde model běží — místní server Ollama nebo koncový bod kompatibilní s OpenAI.',
|
||||
'settings.aiParsing.apiKey': 'Klíč API',
|
||||
'settings.aiParsing.apiKeyHint': 'Ukládá se šifrovaně. Ponechte prázdné pro zachování aktuálního klíče.',
|
||||
'settings.aiParsing.multimodal': 'Odesílat dokumenty jako obrázky',
|
||||
'settings.aiParsing.multimodalHint': 'Pro modely se schopností zpracovat obraz — odešle původní PDF místo extrahovaného textu.',
|
||||
'settings.aiParsing.toast.saved': 'Nastavení AI uloženo',
|
||||
'settings.aiParsing.toast.saveError': 'Nastavení AI se nepodařilo uložit',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -62,6 +62,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.',
|
||||
@@ -328,6 +330,22 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'Verbindung testen',
|
||||
'settings.airtrail.test.success': 'Verbunden — {count} Flug/Flüge gefunden',
|
||||
'settings.airtrail.test.failed': 'Verbindung fehlgeschlagen',
|
||||
'settings.aiParsing.title': 'KI-Verarbeitung',
|
||||
'settings.aiParsing.hint':
|
||||
'Nutze dein eigenes KI-Modell, um Buchungen aus hochgeladenen Dateien auszulesen. Greift nur, wenn dein Administrator kein Modell für die gesamte Instanz konfiguriert hat.',
|
||||
'settings.aiParsing.provider': 'Anbieter',
|
||||
'settings.aiParsing.providerLocal': 'Lokal (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'Modell',
|
||||
'settings.aiParsing.baseUrl': 'Basis-URL',
|
||||
'settings.aiParsing.baseUrlHint': 'Wo das Modell läuft — ein lokaler Ollama-Server oder ein OpenAI-kompatibler Endpunkt.',
|
||||
'settings.aiParsing.apiKey': 'API-Schlüssel',
|
||||
'settings.aiParsing.apiKeyHint': 'Verschlüsselt gespeichert. Leer lassen, um den aktuellen Schlüssel zu behalten.',
|
||||
'settings.aiParsing.multimodal': 'Dokumente als Bilder senden',
|
||||
'settings.aiParsing.multimodalHint': 'Für Modelle mit Bildverständnis — sendet das Original-PDF statt extrahiertem Text.',
|
||||
'settings.aiParsing.toast.saved': 'KI-Einstellungen gespeichert',
|
||||
'settings.aiParsing.toast.saveError': 'KI-Einstellungen konnten nicht gespeichert werden',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -64,6 +64,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.',
|
||||
@@ -322,6 +324,22 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'Test connection',
|
||||
'settings.airtrail.test.success': 'Connected — {count} flight(s) found',
|
||||
'settings.airtrail.test.failed': 'Connection failed',
|
||||
'settings.aiParsing.title': 'AI parsing',
|
||||
'settings.aiParsing.hint':
|
||||
'Use your own AI model to extract bookings from uploaded files. This applies only when your administrator has not configured a model for the whole instance.',
|
||||
'settings.aiParsing.provider': 'Provider',
|
||||
'settings.aiParsing.providerLocal': 'Local (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'Model',
|
||||
'settings.aiParsing.baseUrl': 'Base URL',
|
||||
'settings.aiParsing.baseUrlHint': 'Where the model runs — a local Ollama server or an OpenAI-compatible endpoint.',
|
||||
'settings.aiParsing.apiKey': 'API key',
|
||||
'settings.aiParsing.apiKeyHint': 'Stored encrypted. Leave blank to keep the current key.',
|
||||
'settings.aiParsing.multimodal': 'Send documents as images',
|
||||
'settings.aiParsing.multimodalHint': 'For vision-capable models — sends the original PDF instead of extracted text.',
|
||||
'settings.aiParsing.toast.saved': 'AI settings saved',
|
||||
'settings.aiParsing.toast.saveError': 'Could not save AI settings',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -61,6 +61,8 @@ const settings: TranslationStrings = {
|
||||
'settings.distance': 'Unidad de distancia',
|
||||
'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.',
|
||||
@@ -328,6 +330,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'Probar conexión',
|
||||
'settings.airtrail.test.success': 'Conectado: {count} vuelo(s) encontrado(s)',
|
||||
'settings.airtrail.test.failed': 'Error de conexión',
|
||||
'settings.aiParsing.title': 'Análisis con IA',
|
||||
'settings.aiParsing.hint': 'Usa tu propio modelo de IA para extraer reservas de los archivos subidos. Esto solo se aplica cuando tu administrador no ha configurado un modelo para toda la instancia.',
|
||||
'settings.aiParsing.provider': 'Proveedor',
|
||||
'settings.aiParsing.providerLocal': 'Local (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'Modelo',
|
||||
'settings.aiParsing.baseUrl': 'URL base',
|
||||
'settings.aiParsing.baseUrlHint': 'Donde se ejecuta el modelo: un servidor Ollama local o un endpoint compatible con OpenAI.',
|
||||
'settings.aiParsing.apiKey': 'Clave de API',
|
||||
'settings.aiParsing.apiKeyHint': 'Se almacena cifrada. Déjalo en blanco para mantener la clave actual.',
|
||||
'settings.aiParsing.multimodal': 'Enviar documentos como imágenes',
|
||||
'settings.aiParsing.multimodalHint': 'Para modelos con visión: envía el PDF original en lugar del texto extraído.',
|
||||
'settings.aiParsing.toast.saved': 'Ajustes de IA guardados',
|
||||
'settings.aiParsing.toast.saveError': 'No se han podido guardar los ajustes de IA',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -62,6 +62,8 @@ const settings: TranslationStrings = {
|
||||
'settings.distance': 'Unité de distance',
|
||||
'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.",
|
||||
@@ -333,6 +335,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'Tester la connexion',
|
||||
'settings.airtrail.test.success': 'Connecté — {count} vol(s) trouvé(s)',
|
||||
'settings.airtrail.test.failed': 'Échec de la connexion',
|
||||
'settings.aiParsing.title': 'Analyse par IA',
|
||||
'settings.aiParsing.hint': 'Utilisez votre propre modèle d\'IA pour extraire les réservations des fichiers importés. Cela ne s\'applique que si votre administrateur n\'a pas configuré de modèle pour l\'ensemble de l\'instance.',
|
||||
'settings.aiParsing.provider': 'Fournisseur',
|
||||
'settings.aiParsing.providerLocal': 'Local (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'Modèle',
|
||||
'settings.aiParsing.baseUrl': 'URL de base',
|
||||
'settings.aiParsing.baseUrlHint': 'Emplacement d\'exécution du modèle — un serveur Ollama local ou un point de terminaison compatible OpenAI.',
|
||||
'settings.aiParsing.apiKey': 'Clé API',
|
||||
'settings.aiParsing.apiKeyHint': 'Stockée de façon chiffrée. Laissez vide pour conserver la clé actuelle.',
|
||||
'settings.aiParsing.multimodal': 'Envoyer les documents sous forme d\'images',
|
||||
'settings.aiParsing.multimodalHint': 'Pour les modèles capables d\'analyser des images — envoie le PDF d\'origine au lieu du texte extrait.',
|
||||
'settings.aiParsing.toast.saved': 'Paramètres d\'IA enregistrés',
|
||||
'settings.aiParsing.toast.saveError': 'Impossible d\'enregistrer les paramètres d\'IA',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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} κράτηση/κρατήσεις εισήχθησαν',
|
||||
|
||||
@@ -66,6 +66,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 το ίδιο βράδυ.',
|
||||
@@ -334,6 +336,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'Δοκιμή σύνδεσης',
|
||||
'settings.airtrail.test.success': 'Συνδέθηκε — βρέθηκαν {count} πτήση/πτήσεις',
|
||||
'settings.airtrail.test.failed': 'Η σύνδεση απέτυχε',
|
||||
'settings.aiParsing.title': 'Ανάλυση με AI',
|
||||
'settings.aiParsing.hint': 'Χρησιμοποιήστε το δικό σας μοντέλο AI για την εξαγωγή κρατήσεων από τα αρχεία που ανεβάζετε. Ισχύει μόνο όταν ο διαχειριστής σας δεν έχει ρυθμίσει μοντέλο για ολόκληρη την εγκατάσταση.',
|
||||
'settings.aiParsing.provider': 'Πάροχος',
|
||||
'settings.aiParsing.providerLocal': 'Τοπικό (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'Μοντέλο',
|
||||
'settings.aiParsing.baseUrl': 'Βασικό URL',
|
||||
'settings.aiParsing.baseUrlHint': 'Πού εκτελείται το μοντέλο — ένας τοπικός διακομιστής Ollama ή ένα τελικό σημείο συμβατό με OpenAI.',
|
||||
'settings.aiParsing.apiKey': 'Κλειδί API',
|
||||
'settings.aiParsing.apiKeyHint': 'Αποθηκεύεται κρυπτογραφημένο. Αφήστε το κενό για να διατηρήσετε το τρέχον κλειδί.',
|
||||
'settings.aiParsing.multimodal': 'Αποστολή εγγράφων ως εικόνες',
|
||||
'settings.aiParsing.multimodalHint': 'Για μοντέλα με δυνατότητα όρασης — στέλνει το αρχικό PDF αντί για το εξαγόμενο κείμενο.',
|
||||
'settings.aiParsing.toast.saved': 'Οι ρυθμίσεις AI αποθηκεύτηκαν',
|
||||
'settings.aiParsing.toast.saveError': 'Δεν ήταν δυνατή η αποθήκευση των ρυθμίσεων AI',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -60,6 +60,8 @@ const settings: TranslationStrings = {
|
||||
'settings.distance': 'Távolság 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.',
|
||||
@@ -327,6 +329,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'Kapcsolat tesztelése',
|
||||
'settings.airtrail.test.success': 'Csatlakoztatva — {count} járat található',
|
||||
'settings.airtrail.test.failed': 'A kapcsolat sikertelen',
|
||||
'settings.aiParsing.title': 'AI-feldolgozás',
|
||||
'settings.aiParsing.hint': 'Használd a saját AI-modelledet a foglalások kinyeréséhez a feltöltött fájlokból. Ez csak akkor érvényes, ha a rendszergazda nem állított be modellt az egész példányhoz.',
|
||||
'settings.aiParsing.provider': 'Szolgáltató',
|
||||
'settings.aiParsing.providerLocal': 'Helyi (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'Modell',
|
||||
'settings.aiParsing.baseUrl': 'Alap-URL',
|
||||
'settings.aiParsing.baseUrlHint': 'Ahol a modell fut — helyi Ollama-kiszolgáló vagy OpenAI-kompatibilis végpont.',
|
||||
'settings.aiParsing.apiKey': 'API-kulcs',
|
||||
'settings.aiParsing.apiKeyHint': 'Titkosítva tárolva. Hagyd üresen a jelenlegi kulcs megtartásához.',
|
||||
'settings.aiParsing.multimodal': 'Dokumentumok küldése képként',
|
||||
'settings.aiParsing.multimodalHint': 'Képfelismerésre képes modellekhez — az eredeti PDF-et küldi a kinyert szöveg helyett.',
|
||||
'settings.aiParsing.toast.saved': 'AI-beállítások elmentve',
|
||||
'settings.aiParsing.toast.saveError': 'Az AI-beállítások mentése nem sikerült',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -60,6 +60,8 @@ const settings: TranslationStrings = {
|
||||
'settings.distance': 'Satuan Jarak',
|
||||
'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.',
|
||||
@@ -326,6 +328,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'Uji koneksi',
|
||||
'settings.airtrail.test.success': 'Terhubung — {count} penerbangan ditemukan',
|
||||
'settings.airtrail.test.failed': 'Koneksi gagal',
|
||||
'settings.aiParsing.title': 'Penguraian AI',
|
||||
'settings.aiParsing.hint': 'Gunakan model AI milikmu sendiri untuk mengekstrak pemesanan dari file yang diunggah. Ini hanya berlaku jika administrator belum mengonfigurasi model untuk seluruh instance.',
|
||||
'settings.aiParsing.provider': 'Penyedia',
|
||||
'settings.aiParsing.providerLocal': 'Lokal (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'Model',
|
||||
'settings.aiParsing.baseUrl': 'URL Dasar',
|
||||
'settings.aiParsing.baseUrlHint': 'Tempat model berjalan — server Ollama lokal atau endpoint yang kompatibel dengan OpenAI.',
|
||||
'settings.aiParsing.apiKey': 'Kunci API',
|
||||
'settings.aiParsing.apiKeyHint': 'Disimpan secara terenkripsi. Biarkan kosong untuk mempertahankan kunci saat ini.',
|
||||
'settings.aiParsing.multimodal': 'Kirim dokumen sebagai gambar',
|
||||
'settings.aiParsing.multimodalHint': 'Untuk model yang mendukung visi — mengirim PDF asli alih-alih teks yang diekstrak.',
|
||||
'settings.aiParsing.toast.saved': 'Pengaturan AI disimpan',
|
||||
'settings.aiParsing.toast.saveError': 'Tidak dapat menyimpan pengaturan AI',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -61,6 +61,8 @@ const settings: TranslationStrings = {
|
||||
'settings.distance': 'Unità di Distanza',
|
||||
'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.",
|
||||
@@ -326,6 +328,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'Prova connessione',
|
||||
'settings.airtrail.test.success': 'Connesso — {count} volo/i trovato/i',
|
||||
'settings.airtrail.test.failed': 'Connessione fallita',
|
||||
'settings.aiParsing.title': 'Analisi AI',
|
||||
'settings.aiParsing.hint': 'Usa il tuo modello AI per estrarre le prenotazioni dai file caricati. Vale solo se l\'amministratore non ha configurato un modello per l\'intera istanza.',
|
||||
'settings.aiParsing.provider': 'Provider',
|
||||
'settings.aiParsing.providerLocal': 'Locale (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'Modello',
|
||||
'settings.aiParsing.baseUrl': 'URL di base',
|
||||
'settings.aiParsing.baseUrlHint': 'Dove gira il modello: un server Ollama locale o un endpoint compatibile con OpenAI.',
|
||||
'settings.aiParsing.apiKey': 'Chiave API',
|
||||
'settings.aiParsing.apiKeyHint': 'Memorizzata in forma cifrata. Lascia vuoto per mantenere la chiave attuale.',
|
||||
'settings.aiParsing.multimodal': 'Invia i documenti come immagini',
|
||||
'settings.aiParsing.multimodalHint': 'Per i modelli con capacità visive: invia il PDF originale invece del testo estratto.',
|
||||
'settings.aiParsing.toast.saved': 'Impostazioni AI salvate',
|
||||
'settings.aiParsing.toast.saveError': 'Impossibile salvare le impostazioni AI',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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} 件の予約をインポートしました',
|
||||
|
||||
@@ -60,6 +60,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':
|
||||
'その日を最適化する際、朝に目覚める宿泊先を起点にし、その晩にチェックインする宿泊先を終点としてルートを組みます。',
|
||||
@@ -303,6 +305,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': '接続をテスト',
|
||||
'settings.airtrail.test.success': '接続成功 — {count} 件のフライトが見つかりました',
|
||||
'settings.airtrail.test.failed': '接続に失敗しました',
|
||||
'settings.aiParsing.title': 'AI解析',
|
||||
'settings.aiParsing.hint': 'アップロードしたファイルから予約情報を抽出するために、自分のAIモデルを使用します。これは、管理者がインスタンス全体のモデルを設定していない場合にのみ適用されます。',
|
||||
'settings.aiParsing.provider': 'プロバイダー',
|
||||
'settings.aiParsing.providerLocal': 'ローカル (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'モデル',
|
||||
'settings.aiParsing.baseUrl': 'ベースURL',
|
||||
'settings.aiParsing.baseUrlHint': 'モデルの実行場所 — ローカルのOllamaサーバー、またはOpenAI互換のエンドポイント。',
|
||||
'settings.aiParsing.apiKey': 'APIキー',
|
||||
'settings.aiParsing.apiKeyHint': '暗号化して保存されます。現在のキーを保持する場合は空欄のままにしてください。',
|
||||
'settings.aiParsing.multimodal': 'ドキュメントを画像として送信',
|
||||
'settings.aiParsing.multimodalHint': '画像認識対応モデル向け — 抽出したテキストの代わりに元のPDFを送信します。',
|
||||
'settings.aiParsing.toast.saved': 'AI設定を保存しました',
|
||||
'settings.aiParsing.toast.saveError': 'AI設定を保存できませんでした',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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}개 예약을 가져왔습니다',
|
||||
|
||||
@@ -61,6 +61,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':
|
||||
'하루 일정을 최적화할 때, 아침에 머무는 숙소에서 경로를 시작하고 그날 저녁에 체크인하는 숙소에서 경로를 끝냅니다.',
|
||||
@@ -318,6 +320,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': '연결 테스트',
|
||||
'settings.airtrail.test.success': '연결됨 — {count}개 항공편을 찾았습니다',
|
||||
'settings.airtrail.test.failed': '연결에 실패했습니다',
|
||||
'settings.aiParsing.title': 'AI 분석',
|
||||
'settings.aiParsing.hint': '업로드한 파일에서 예약 정보를 추출할 때 직접 지정한 AI 모델을 사용하세요. 이 설정은 관리자가 인스턴스 전체에 모델을 설정하지 않은 경우에만 적용됩니다.',
|
||||
'settings.aiParsing.provider': '제공자',
|
||||
'settings.aiParsing.providerLocal': '로컬 (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': '모델',
|
||||
'settings.aiParsing.baseUrl': '기본 URL',
|
||||
'settings.aiParsing.baseUrlHint': '모델이 실행되는 위치 — 로컬 Ollama 서버 또는 OpenAI 호환 엔드포인트입니다.',
|
||||
'settings.aiParsing.apiKey': 'API 키',
|
||||
'settings.aiParsing.apiKeyHint': '암호화되어 저장됩니다. 현재 키를 유지하려면 비워 두세요.',
|
||||
'settings.aiParsing.multimodal': '문서를 이미지로 전송',
|
||||
'settings.aiParsing.multimodalHint': '비전 지원 모델용 — 추출된 텍스트 대신 원본 PDF를 전송합니다.',
|
||||
'settings.aiParsing.toast.saved': 'AI 설정이 저장되었습니다',
|
||||
'settings.aiParsing.toast.saveError': 'AI 설정을 저장할 수 없습니다',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -60,6 +60,8 @@ const settings: TranslationStrings = {
|
||||
'settings.distance': 'Afstandseenheid',
|
||||
'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.',
|
||||
@@ -326,6 +328,21 @@ const settings: TranslationStrings = {
|
||||
'settings.airtrail.test.button': 'Verbinding testen',
|
||||
'settings.airtrail.test.success': 'Verbonden — {count} vlucht(en) gevonden',
|
||||
'settings.airtrail.test.failed': 'Verbinding mislukt',
|
||||
'settings.aiParsing.title': 'AI-verwerking',
|
||||
'settings.aiParsing.hint': 'Gebruik je eigen AI-model om boekingen uit geüploade bestanden te halen. Dit geldt alleen als je beheerder geen model voor de hele instantie heeft ingesteld.',
|
||||
'settings.aiParsing.provider': 'Provider',
|
||||
'settings.aiParsing.providerLocal': 'Lokaal (Ollama)',
|
||||
'settings.aiParsing.providerOpenai': 'OpenAI',
|
||||
'settings.aiParsing.providerAnthropic': 'Anthropic',
|
||||
'settings.aiParsing.model': 'Model',
|
||||
'settings.aiParsing.baseUrl': 'Basis-URL',
|
||||
'settings.aiParsing.baseUrlHint': 'Waar het model draait — een lokale Ollama-server of een OpenAI-compatibel endpoint.',
|
||||
'settings.aiParsing.apiKey': 'API-sleutel',
|
||||
'settings.aiParsing.apiKeyHint': 'Versleuteld opgeslagen. Laat leeg om de huidige sleutel te behouden.',
|
||||
'settings.aiParsing.multimodal': 'Documenten als afbeeldingen versturen',
|
||||
'settings.aiParsing.multimodalHint': 'Voor modellen met beeldherkenning — verstuurt de originele PDF in plaats van geëxtraheerde tekst.',
|
||||
'settings.aiParsing.toast.saved': 'AI-instellingen opgeslagen',
|
||||
'settings.aiParsing.toast.saveError': 'AI-instellingen konden niet worden opgeslagen',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user