Compare commits

..

49 Commits

Author SHA1 Message Date
Maurice 6f21eba216 fix(import): refresh costs after a booking review so imported expenses appear without a reload
Imported bookings auto-create their linked budget items server-side, but the saving client suppresses its own budget:created echo, so the Costs list stayed stale until a manual reload. Reload the budget items when the review session ends.
2026-06-26 09:56:22 +02:00
Maurice 50eb88511c fix(import): resolve an imported transport's day from its parsed dates
A reviewed transport (e.g. a rental car) arrived with only its parsed pick-up/return dates and no day_id, so the modal kept just the time and saved a bare "HH:MM" with no date. Resolve start/end day from the parsed dates (exact match, else nearest trip day) so the booking lands on the right days.
2026-06-26 09:46:36 +02:00
Maurice ca3ffea3ea fix(reservations): skip un-geocoded endpoints instead of failing the save
reservation_endpoints.lat/lng are NOT NULL, so saving a reviewed transport whose pick-up/return couldn't be geocoded threw a 500 and lost the whole booking (dates, linked cost). Skip those rows; the dates still persist on reservation_time/reservation_end_time.
2026-06-26 09:31:49 +02:00
Maurice e934fe43f1 fix(import): keep the parse-progress widget across a reload
Persist the background-import tasks (id/trip/status only) and re-fetch each job's status on mount, so a parse still running when the page reloads keeps its widget instead of vanishing; expired jobs (404) are dropped and a restored 'done' task re-fetches its items.
2026-06-26 09:08:44 +02:00
Maurice b175ef4626 fix(extract): backfill booking code/total and harden the reference match
Apply the deterministic confirmation-code and total fill to vendor-template results too (not just model output), and require the captured reference to contain a digit so a bare 'Confirmation'/'Reference' label no longer grabs the next prose word.
2026-06-26 09:08:36 +02:00
Maurice 9aaf313d59 feat(extract): add Expedia and rental-broker booking templates
Pull the hotel/rental fields these vendors print in a stable text layout (name, address, stay/pickup dates, price, reference) deterministically, so the import stops depending on the local model for them. Handles German long/abbreviated months and English dates incl. 12-hour and comma forms.
2026-06-26 09:08:25 +02:00
Maurice c5fb76da7b fix(import): create linked costs and accommodations from reviewed bookings
Reviewing an imported booking saves it through the normal reservation
form, which dropped the parsed price (so no linked cost was created) and
only created the accommodation when both nights matched a trip day.
Carry the parsed price into a linked cost on save, and create the
accommodation from whichever day the check-in/out dates resolve to.
2026-06-25 23:56:21 +02:00
Maurice 628830011d feat(import): parse bookings in the background with a progress widget
Parsing a booking can take a while on a CPU host, so don't hold the
upload modal open for it. The async import endpoint returns a job id
right away; the parse runs server-side (one at a time per user) and
pushes progress over the user's WebSocket, and a small widget in the
bottom corner tracks it while the user keeps navigating and editing.
A finished job opens the per-item review from the widget.
2026-06-25 23:56:21 +02:00
Maurice c92c6bc07c feat(extract): drive local parsing through a layered extraction router
The single-shot prompt was unreliable on multi-leg flights and longer
documents, and slow on a CPU host. For the local provider, run a small
router instead:

- deterministic vendor templates first, with no model call at all
- exactly one grammar-enforced call per document via Ollama's native
  `format` (flights as a flat array of legs, everything else as one flat
  reservation, the type picked from keywords or a union schema)
- booking-wide fields (booking reference, total price, the overnight
  arrival day) filled deterministically from the text afterwards, and
  dates coerced to ISO so a natural-language date can't slip through

Recommend qwen2.5 in the AI-parsing settings instead of NuExtract.
2026-06-25 23:56:20 +02:00
Maurice ccf0703f23 feat(import): review each parsed booking before it's saved
Instead of writing parsed items straight to the trip, the import opens the
normal edit modal pre-filled for each one, so you can check and fix it before
saving — useful when a model guesses a wrong date or address. Hotels gained an
editable address field; on save an existing place is matched by name, otherwise
the reviewed address is geocoded and a new place is created.
2026-06-25 10:27:19 +02:00
Maurice 7291d9c52f fix(admin): tidy the AI parsing settings and recommend the 2B model
The provider picker is the shared CustomSelect now and the form is split into
clear sections rather than a flat stack of inputs. NuExtract 2.0 2B is the
recommended default — fastest on a CPU-only host and MIT licensed; the 4B
carries a non-commercial licence, so it's no longer flagged as recommended.
2026-06-25 10:27:19 +02:00
Maurice 156b8da37e feat(extract): drive NuExtract with its native template
NuExtract isn't an instruct model — fed a plain chat prompt it just echoes the
schema back. Detect a NuExtract model by id and talk to it the way the model
cards document: the JSON template inlined in a single user message, no system
prompt, no json_schema, temperature 0. Its flat result is mapped back to the
same KiReservation shape the rest of the pipeline already uses, so nothing
downstream changes; every other model keeps the generic prompt.

Money is taken as a verbatim string and parsed locally (German "1.580,22 €"
otherwise comes back as 1.49772), a rental car's pickup/return ride the from/to
fields so a stray form label doesn't become the location, and a lodging with no
name falls back to its address instead of being dropped.
2026-06-25 10:27:01 +02:00
Maurice cee4b87cc9 fix(extract): refresh accommodations after a booking import
A freshly imported hotel links to an accommodation that lives outside the
trip store, so loadTrip alone left the reservation edit modal with blank
place/date fields. Reload the accommodations list once the import finishes.
2026-06-24 23:29:59 +02:00
Maurice 223f5ce9bc feat(extract): create a linked cost from the booking price on import
When a confirmation carries a total price, record it as a real expense
linked to the reservation (in the matching Costs category) instead of
leaving the amount in metadata only. Gated on the Costs addon.
2026-06-24 23:29:59 +02:00
Maurice 5fa79bba52 feat(extract): capture seat, class, platform, price + event venue contact
Request and map root-level seat/class/platform and a total price/currency into reservation metadata (shown on the card; price reuses the existing label). Read both the root and reservationFor and tolerate common field-name aliases (priceAmount, priceCurrencyISO4217Code, fareClass, ...) since models name these inconsistently. Also capture event/attraction venue telephone + url onto the auto-created place, matching lodging/restaurant.
2026-06-24 23:04:24 +02:00
Maurice 23d5a5bd9c perf(extract): cap LLM input at 4000 chars for CPU-only speed
On a GPU-less host the model's prompt-eval time scales with input length and dominates total latency. Booking details sit at the top of a confirmation, so capping the extracted text at 4000 chars (was 8000) roughly halves extraction time (~50s warm for a capable local 7B model) with no loss of fields on real hotel/rental confirmations. Tunable if a long multi-segment itinerary needs more.
2026-06-24 22:44:55 +02:00
Maurice a5d05cb92e feat(extract): fill transport/booking fields, geocode endpoints, assign days
- rental car: request+map dropoffLocation, emit pickup->return from/to endpoints, set a location string (G1/G2/G3). - geocode endpoints (stations/stops/terminals/rental desks) on confirm via Nominatim; mapper now emits coordless named endpoints and confirm persists only the geocoded ones (G6). - assign every dated booking to the nearest trip day so it still shows when slightly out of range, and keep hotel accommodation from vanishing when a check date misses (G5/G10). - fix bus mislabelled as train + add bus_number metadata (G7/G8), flag malformed boats (G9), accept root start/end time for events (G11). - raise the local-LLM timeout to 300s for CPU-only Ollama.
2026-06-24 22:23:13 +02:00
Maurice ac03b7ca13 fix(extract): make AI imports reliable and fast on local models
client: the import call inherited the global 8s axios timeout and aborted long LLM extractions even though the server finished it; remove the timeout. server: raise the OpenAI-compatible LLM timeout 60s->180s (a cold Ollama model can take ~45s to first token). server: cap extracted text to 8000 chars before the LLM - multi-page T&C tails (30k+ chars) overflowed the context window, truncating the relevant head and making CPU inference crawl; booking details sit at the top.
2026-06-24 21:20:20 +02:00
Maurice 22813f8d81 fix(extract): auto-run the AI fallback when the addon is enabled
Booking import only fell back to the LLM when each user flipped an 'always retry with AI' toggle, so by default files kitinerary returned nothing for just failed. Run the fallback automatically whenever the AI Parsing addon is on (fallback-on-empty); drop the now-redundant per-user toggle and its setting.
2026-06-24 21:20:19 +02:00
jubnl 186625591a feat(extract): extract data using LLM 2026-06-24 18:45:52 +02:00
jubnl 49fb2fded2 chore(wiki): make sure that all environement variables are properly documented 2026-06-24 14:03:39 +02:00
github-actions[bot] 4cd4c9c8d8 chore: bump version to 3.1.2 [skip ci] 2026-06-23 19:24:13 +00:00
jubnl 6cc8908f87 fix(tests): memory leak 2026-06-23 21:23:39 +02:00
Maurice 68f48bc070 ci: give client test workers 8 GB heap (no coverage) to fix worker OOM (#1258) 2026-06-23 21:23:39 +02:00
Maurice 76d8abb44d ci: run client tests without coverage to avoid the v8 report OOM (#1258) 2026-06-23 21:23:39 +02:00
Maurice 91c350c946 ci: raise client coverage heap to 12 GB for the v8 report phase (#1258) 2026-06-23 21:23:39 +02:00
Maurice 1e4a9a95c2 ci: raise Node heap for the client coverage run to fix OOM (#1258) 2026-06-23 21:23:39 +02:00
Maurice fe54f45d62 fix(map): draw the route line to and from the day's accommodation (#1275)
The map route ran first-activity to last-activity only, while the sidebar
already showed the hotel-to-first-stop and last-stop-to-hotel legs with
their drive times. Feed the day's accommodation bookends into the map
route too, reusing the same getDayBookendHotels lookup and the
"optimize from accommodation" gate, so the drawn line starts and ends at
the hotel, including single-activity and transfer days.
2026-06-23 21:23:39 +02:00
Maurice b36c9931b3 fix(costs): allow recording an expense with no split or payer (#1286)
Adding an expense required at least one participant, so a cost you only
want to record — e.g. a booking paid on-site later — could not be saved
without splitting it. Drop the participant requirement: with nobody
selected the expense saves as a recorded total, counted in the trip
total and shown as Unfinished, and kept out of settlements until
who-paid is filled in. The shared schema and server already supported
this case.
2026-06-23 21:23:39 +02:00
Maurice c1fe1d2d6a fix(packing): keep a custom category when its last item is removed (#1289)
Removing the only item of a user-created category deleted the whole
category. Turn that row back into the existing ... placeholder in
place instead, so the category keeps its position and colour; adding an
item reuses the placeholder slot. Deleting the placeholder (or the
category menu) still removes an empty category.
2026-06-23 21:23:39 +02:00
Maurice ebbbf91d60 fix(dashboard): show an error instead of a blank trip list when the server is unreachable (#1283)
When the backend or identity provider was unreachable, a returning user with a
persisted session landed on the dashboard with an empty trip grid and no error.
That looks identical to a logged-in user who simply has no trips, so people
assumed their data had been lost.

Three client-side layers were quietly swallowing the failure: the auth check
only cleared state on a 401, so a 5xx or a network error left the stale session
in place and kept rendering the protected route; the offline-first trip repo
turned a failed fetch into the empty cache without throwing; and the dashboard
had neither an error nor an empty state, so a blank grid meant both "outage" and
"no trips".

The auth check now tells genuine offline (keep serving the cache silently, the
PWA happy path) apart from a server outage while online (keep the session but
flag it). The dashboard shows a reassuring "couldn't reach the server, your
trips are safe" banner with a retry, and a real zero-trip account finally gets a
proper empty state so the two cases never look alike. New strings added across
all locales.
2026-06-23 21:23:39 +02:00
Maurice 328d1c9468 fix(auth): keep the last admin when OIDC claims would demote it (#1274)
On OIDC-only instances the bootstrap admin (first SSO user) rarely carries the configured admin claim, so a forced re-login — e.g. after a JWT-secret rotation — re-derived its role purely from claims and demoted it to user, locking the instance out with no recovery. The OIDC login role sync now skips a downgrade that would strip the last remaining admin, and the admin user-update endpoint guards the same case.
2026-06-23 21:23:39 +02:00
Maurice 48ebdff2d5 feat(planner): bring back the Google Maps route export button (#1255)
The day-plan route bar lost its Open in Google Maps action in the 3.1.0 redesign. A small button with the Google logo (monochrome, theme-aware) now sits next to the Route toggle and opens the day stops, in planned order, as a Google Maps directions link in a new tab.
2026-06-23 21:23:39 +02:00
Maurice 457a42b229 fix(admin): show non-Docker update steps when not running in Docker (#1269)
The "How to Update" modal always rendered Docker commands and claimed the instance runs in Docker, even on bare-metal / LXC installs like Proxmox Community Scripts. It now branches on the is_docker flag the backend already returns: non-Docker installs get a generic "re-run your install method" note plus a link to the update guide. Docker stays the default when the flag is absent, so existing installs are unaffected.
2026-06-23 21:23:39 +02:00
Alejandro Pinar Ruiz 7df5956920 feat(helm): add annotations support for PVCs (#1270)
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-06-23 21:23:39 +02:00
Maurice 0d50d5d7c3 fix(atlas): give the country-GeoJSON fetch a longer timeout (#1254)
The gzipped admin0 GeoJSON is still a few MB, so behind a slow reverse proxy or Cloudflare Tunnel it could exceed the global 8s axios timeout and abort, leaving the map with no countries. It now gets a 30s per-request timeout, matching the existing /maps/pois exception.
2026-06-23 21:23:39 +02:00
Maurice 4a3aa478c6 fix(dashboard): add a text-shadow so spotlight and card titles stay legible (#1267)
When no trip is ongoing the spotlight falls back to a trip gradient, and several of those are light enough that the white title vanished in light mode. A subtle text-shadow on the hero title and trip-card names keeps them readable without affecting dark covers or dark mode.
2026-06-23 21:23:39 +02:00
Maurice abee2fc088 fix(costs): move the unfinished marker to the category icon on mobile (#1266)
A long expense title pushed the "Unfinished" pill into the price on narrow screens. On mobile the status now shows as a small marker on the category icon, freeing the title and price row; desktop keeps the labelled pill.
2026-06-23 21:23:39 +02:00
Maurice e40465ba1f test(days): bump over-long-time assertion to the new 250 limit (#1252)
Follow-up to raising the day-note time cap to 250: the unit test still sent 151 chars expecting a 400, which now passes validation and fell through to an unmocked service call.
2026-06-23 21:23:39 +02:00
Maurice 8dab26fe7b fix(chart): allow setting storageClassName on PVCs (#1261)
The PVC templates rendered no storageClassName and values exposed no key, so clusters without a default StorageClass (or needing a specific class) couldn't install. Add persistence.{data,uploads}.storageClassName, omitted when empty so the default class is still used.
2026-06-23 21:23:39 +02:00
Maurice 7459067b2e fix(pdf): show photos for OSM places in the trip PDF (#1130)
The PDF photo pre-fetch only fired for places with a google_place_id, so OSM/Nominatim places (osm_id only) fell back to category icons even though they show photos in-app. Recover osm_id from the full places pool (the assignment projection drops it) and key the photo off google_place_id || osm_id || coords, matching the UI.
2026-06-23 21:23:39 +02:00
Maurice a2c552f04d fix(days): align note time limit to 250 and keep toasts above modal blur (#1252)
The day-note 'time' field capped at 150 server-side while the dialog and shared schema allow 250, so 151-250 char notes 400'd with a confusing 'time must be 150...' message. Raise the controller and MCP limits to 250. Also lift the toast container above modal overlays so the error toast isn't rendered behind the modal's backdrop blur.
2026-06-23 21:23:39 +02:00
Maurice 27762458e6 fix(dashboard): count archived trips in travel stats (#1264)
The trips/days widgets filtered out archived trips while places, countries and flight distance did not, so archiving a trip zeroed only those two. Drop the is_archived filter so all stats stay consistent.
2026-06-23 21:23:39 +02:00
Maurice adbe15abc4 fix(security): allow same-origin PDF previews under CSP (#1253)
Firefox/Chrome enforce object-src, so object-src 'none' blocked the inline <object> PDF preview (worked only in Safari). Relax to 'self' for same-origin file previews.
2026-06-23 21:23:39 +02:00
Maurice 982b99f0f6 chore: add ca_profile.xml for Unraid Community Apps submission 2026-06-23 21:23:39 +02:00
Neil Soult 6a797a39ae fix(atlas): gzip-compress responses so large country GeoJSON loads behind reverse proxies (#1262)
The admin-0 country GeoJSON served at /api/addons/atlas/countries/geo is
~30 MB uncompressed. With no compression in the request pipeline the
transfer aborts (~8s, net::ERR_FAILED despite a 200) behind reverse
proxies / Cloudflare Tunnel, so the Atlas map never colours visited
countries. LAN is unaffected.

Add the `compression` middleware to the shared applyGlobalMiddleware
pipeline (gzip brings ~30 MB down to ~4 MB). text/event-stream is
excluded so the /mcp StreamableHTTP (SSE) transport is not buffered.

Adds BOOT-008 asserting content-encoding: gzip on the geo endpoint.

Fixes #1254

Co-authored-by: pai <pai@stabpablo.eu>
2026-06-23 21:23:39 +02:00
jubnl d2cd317070 chore: dockerignore spec.ts files 2026-06-23 21:23:39 +02:00
jubnl 6ab6d79494 fix(budget): scale category bars relative to top category 2026-06-23 21:23:39 +02:00
jubnl d35972db39 fix(budget): accept comma decimal separator in expense amounts
The expense Total Amount, per-person "Who paid", and settlement amount
inputs used type="number" with a bare parseFloat. On desktop the number
input normalized comma→dot for free, but mobile keyboards drop the comma
before onChange fires, so parseFloat("39,99") silently became 39.

Switch the three inputs to type="text" inputMode="decimal" and normalize
comma→dot in their onChange handlers, matching the pattern already used
by the other budget inputs (BudgetPanelInlineEditCell, BudgetPanelAddItemRow,
DashboardPage). Both comma and dot now work on every device.

Closes #1256
2026-06-23 21:23:39 +02:00
109 changed files with 4696 additions and 504 deletions
+3 -15
View File
@@ -46,23 +46,11 @@ COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY server/package.json ./server/
# better-sqlite3 native addon requires build tools (purged after compile).
# kitinerary-extractor for booking-confirmation import:
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
# arm64 — apt package (KDE publishes no arm64 static binary)
RUN apt-get update && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
libkitinerary-bin && \
npm ci --workspace=server --omit=dev && \
ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then \
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
rm /tmp/ki.tgz; \
else \
apt-get install -y --no-install-recommends libkitinerary-bin && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
fi && \
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
apt-get purge -y python3 build-essential && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "3.1.1",
"version": "3.1.2",
"private": true,
"type": "module",
"scripts": {
+2
View File
@@ -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 />} />
+53 -3
View File
@@ -41,6 +41,7 @@ import {
type BookingImportPreviewItem,
type BookingImportPreviewResponse,
type BookingImportConfirmResponse,
type BookingImportMode,
} from '@trek/shared'
import { getSocketId } from './websocket'
import { isReachable, probeNow } from '../sync/connectivity'
@@ -441,6 +442,41 @@ export const adminApi = {
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
addons: () => apiClient.get('/admin/addons').then(r => r.data),
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
// Local LLM (Ollama) management for the AI-parsing addon.
llmLocalModels: (baseUrl: string): Promise<{ models: { name: string; size: number }[] }> =>
apiClient.get('/admin/llm/local/models', { params: { baseUrl } }).then(r => r.data),
/** Pull a model, streaming Ollama's NDJSON progress to `onProgress`. */
llmLocalPull: async (
baseUrl: string,
model: string,
onProgress: (p: { status?: string; total?: number; completed?: number; error?: string }) => void,
): Promise<void> => {
const res = await fetch('/api/admin/llm/local/pull', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseUrl, model }),
})
if (!res.ok || !res.body) {
let msg = `Pull failed (${res.status})`
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
throw new Error(msg)
}
const reader = res.body.getReader()
const dec = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
}
}
},
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
@@ -624,17 +660,31 @@ export const reservationsApi = {
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
importBookingPreview: (tripId: number | string, files: File[]): Promise<BookingImportPreviewResponse> => {
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
fd.append('mode', mode)
// No client-side timeout: kitinerary + LLM extraction routinely exceeds the
// global 8s default (a cold local model alone can take ~45s).
return apiClient.post(`/trips/${tripId}/reservations/import/booking`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
},
importBookingConfirm: (tripId: number | string, items: BookingImportPreviewItem[]): Promise<BookingImportConfirmResponse> =>
apiClient.post(`/trips/${tripId}/reservations/import/booking/confirm`, { items }).then(r => r.data),
// Start a background parse: returns a job id at once; progress + result arrive
// over the WebSocket (import:progress / import:done / import:error).
importBookingAsync: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<{ jobId: string }> => {
const fd = new FormData()
for (const f of files) fd.append('files', f)
fd.append('mode', mode)
return apiClient.post(`/trips/${tripId}/reservations/import/booking/async`, fd, { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 0 }).then(r => r.data)
},
// Poll a background job — recovery path when a WebSocket push was missed.
importJobStatus: (tripId: number | string, jobId: string): Promise<{ status: 'running' | 'done' | 'error'; done: number; total: number; result?: BookingImportPreviewResponse; error?: string }> =>
apiClient.get(`/trips/${tripId}/reservations/import/jobs/${jobId}`).then(r => r.data),
}
export const healthApi = {
features: (): Promise<{ bookingImport: boolean }> => apiClient.get('/health/features').then(r => r.data),
features: (): Promise<{ bookingImport: boolean; aiParsing: boolean }> => apiClient.get('/health/features').then(r => r.data),
}
export const weatherApi = {
+228 -2
View File
@@ -4,7 +4,8 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
@@ -298,7 +299,12 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
</span>
</div>
{integrationAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'llm_parsing' && addon.enabled && (
<LlmParsingConfig addon={addon} />
)}
</div>
))}
</div>
)}
@@ -309,6 +315,226 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
}
const MASKED = '••••••••'
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
/** Curated models the local extractor is tuned for, pullable via Ollama. The router
* uses the strong model for flights/multi-item docs and the small one (when installed)
* for simple single-item bookings — so a host only needs these two. */
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
{ id: 'qwen2.5:7b', label: 'Qwen2.5 — 7B', note: 'Recommended · reliable for flights & multi-item bookings · Apache-2.0', recommended: true, vision: false },
{ id: 'qwen2.5:3b', label: 'Qwen2.5 — 3B', note: 'Optional · used automatically for simple bookings (~3× faster) · Apache-2.0', recommended: false, vision: false },
]
/**
* Instance-wide AI-parsing config. When set, applies to the whole instance and
* overrides per-user config (see server llmConfig.ts). The API key is masked on
* read; an unchanged mask is treated as a no-op by the server. For the local
* provider, it also lists installed Ollama models and can pull NuExtract models.
*/
function LlmParsingConfig({ addon }: { addon: Addon }) {
const toast = useToast()
const cfg = (addon.config ?? {}) as Record<string, unknown>
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
const [saving, setSaving] = useState(false)
// Local-provider model management.
const [installed, setInstalled] = useState<string[]>([])
const [modelsErr, setModelsErr] = useState('')
const [loadingModels, setLoadingModels] = useState(false)
const [pulling, setPulling] = useState<string | null>(null)
const [pullPct, setPullPct] = useState(0)
const [pullStatus, setPullStatus] = useState('')
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
const loadModels = async () => {
if (provider !== 'local') return
setLoadingModels(true)
setModelsErr('')
try {
const res = await adminApi.llmLocalModels(effectiveUrl)
setInstalled(res.models.map(m => m.name))
} catch (e: unknown) {
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
setInstalled([])
} finally {
setLoadingModels(false)
}
}
// Load installed models when the local provider is active.
useEffect(() => {
if (provider === 'local') loadModels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider])
const pull = async (id: string) => {
if (pulling) return
setPulling(id)
setPullPct(0)
setPullStatus('starting…')
try {
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
if (p.error) throw new Error(p.error)
if (p.status) setPullStatus(p.status)
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
})
toast.success('Model pulled')
setModel(id)
await loadModels()
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Pull failed')
} finally {
setPulling(null)
setPullPct(0)
setPullStatus('')
}
}
const save = async () => {
setSaving(true)
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
toast.success('Saved')
} catch {
toast.error('Failed to save')
} finally {
setSaving(false)
}
}
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
const providerOptions = [
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
]
return (
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
<div className="max-w-2xl space-y-6">
<p className="text-xs text-content-faint">
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
</p>
{/* Connection */}
<section className="space-y-3">
<div className={sectionCls}>Connection</div>
<div>
<span className={labelCls}>Provider</span>
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
</div>
{provider !== 'anthropic' && (
<label className="block">
<span className={labelCls}>Base URL</span>
<input className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
</label>
)}
<label className="block">
<span className={labelCls}>API key</span>
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
</label>
{provider === 'anthropic' && (
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text scanned PDFs need Anthropic.</p>
)}
</section>
{/* Model */}
<section className="space-y-3">
<div className={sectionCls}>Model</div>
<label className="block">
<input className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
</label>
{/* Local model management (Ollama) */}
{provider === 'local' && (
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
{loadingModels ? 'Loading…' : 'Refresh'}
</button>
</div>
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
{!modelsErr && installed.length === 0 && !loadingModels && (
<p className="text-xs text-content-faint">No models installed yet pull one below.</p>
)}
{installed.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{installed.map(name => (
<button
key={name}
title={name}
onClick={() => setModel(name)}
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
>
{name}
</button>
))}
</div>
)}
<div className="border-t border-edge-secondary pt-3">
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
<div className="space-y-1">
{RECOMMENDED_MODELS.map(m => {
const installedHere = isInstalled(m.id)
const isPulling = pulling === m.id
const active = model === m.id
return (
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-content">{m.label}</span>
{m.recommended && (
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
)}
</div>
<div className="text-xs text-content-faint">{m.note}</div>
{isPulling && (
<div className="mt-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
</div>
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
</div>
)}
</div>
{installedHere ? (
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
{active ? 'Selected' : 'Use'}
</button>
) : (
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
{isPulling ? 'Pulling…' : 'Pull'}
</button>
)}
</div>
)
})}
</div>
</div>
</div>
)}
</section>
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
)
}
interface AddonRowProps {
addon: Addon
onToggle: (addon: Addon) => void
@@ -0,0 +1,163 @@
import ReactDOM from 'react-dom'
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addListener, removeListener } from '../../api/websocket'
import { reservationsApi } from '../../api/client'
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
/**
* Global, route-independent widget (bottom-right) that tracks background booking
* imports. Mounted once at the app root so it survives navigation. It listens to the
* user's WebSocket for import:progress / import:done / import:error and reflects each
* job; a finished job offers a "review" action that takes the user to the trip, where
* the per-item review flow opens. Polls running jobs as a backstop for missed pushes.
*/
export default function BackgroundTasksWidget() {
const { t } = useTranslation()
const navigate = useNavigate()
const tasks = useBackgroundTasksStore((s) => s.tasks)
const setProgress = useBackgroundTasksStore((s) => s.setProgress)
const setDone = useBackgroundTasksStore((s) => s.setDone)
const setError = useBackgroundTasksStore((s) => s.setError)
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
// On (re)load, reconcile tasks restored from localStorage with the server: a parse
// that was still running when the page reloaded must keep its widget, so re-fetch each
// job's real status (and its parsed items) once. A job the server has since dropped
// (404, expired) is removed so no stale card lingers.
const didRehydrate = useRef(false)
useEffect(() => {
if (didRehydrate.current) return
didRehydrate.current = true
const restored = useBackgroundTasksStore.getState().tasks
for (const task of restored) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch((err: { response?: { status?: number } }) => {
if (err?.response?.status === 404) dismiss(task.id)
})
}
// run once on mount against whatever was rehydrated from storage
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Server pushes import:* to the user on whatever page they're on.
useEffect(() => {
const handler = (e: Record<string, unknown>) => {
const type = typeof e.type === 'string' ? e.type : ''
if (!type.startsWith('import:')) return
const id = String(e.jobId ?? '')
const tripId = String(e.tripId ?? '')
if (!id) return
if (type === 'import:progress') setProgress(id, tripId, Number(e.done ?? 0), Number(e.total ?? 1))
else if (type === 'import:done') {
const result = e.result as { items?: unknown[]; warnings?: string[] } | undefined
setDone(id, tripId, (result?.items ?? []) as never, result?.warnings ?? [])
} else if (type === 'import:error') setError(id, tripId, String(e.message ?? 'error'))
}
addListener(handler)
return () => removeListener(handler)
}, [setProgress, setDone, setError])
// Backstop: poll jobs whose state we still need — running ones (in case a WebSocket push
// was missed) and a restored 'done' task whose items haven't been re-fetched yet (so a
// failed one-shot rehydrate self-heals instead of getting stuck on "preview empty").
useEffect(() => {
const pending = tasks.filter((task) => task.status === 'running' || (task.status === 'done' && task.items === undefined))
if (pending.length === 0) return
const iv = setInterval(() => {
for (const task of pending) {
reservationsApi
.importJobStatus(task.tripId, task.id)
.then((s) => {
if (s.status === 'done') setDone(task.id, task.tripId, (s.result?.items ?? []) as never, s.result?.warnings ?? [])
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
else setProgress(task.id, task.tripId, s.done, s.total)
})
.catch(() => {})
}
}, 5000)
return () => clearInterval(iv)
}, [tasks, setProgress, setDone, setError])
if (tasks.length === 0) return null
const review = (task: BackgroundImportTask) => {
requestReview(task.id)
navigate(`/trips/${task.tripId}`)
}
return ReactDOM.createPortal(
<div
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
>
{tasks.map((task) => (
<div
key={task.id}
className="bg-surface-card"
style={{ borderRadius: 12, border: '1px solid var(--border-primary)', boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '11px 13px', backdropFilter: 'blur(8px)', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
<div style={{ flexShrink: 0, marginTop: 1 }}>
{(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && <Loader2 size={16} className="animate-spin" color="var(--accent)" />}
{task.status === 'done' && task.items !== undefined && <CheckCircle2 size={16} color="#10b981" />}
{task.status === 'error' && <AlertCircle size={16} color="#ef4444" />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.label}
</div>
{task.status === 'running' && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
{t('reservations.import.parsing')}
{task.total > 1 ? ` · ${task.done}/${task.total}` : ''}
</div>
)}
{task.status === 'done' && (
task.items === undefined ? (
// Restored from a reload; items are being re-fetched (see the poll backstop).
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
) : task.items.length > 0 ? (
<button
onClick={() => review(task)}
className="bg-accent text-accent-text"
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.import')}
</button>
) : (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.previewEmpty')}</div>
)
)}
{task.status === 'error' && (
<div style={{ fontSize: 11, color: '#b91c1c', marginTop: 1, whiteSpace: 'pre-wrap' }}>{task.error}</div>
)}
</div>
{task.status !== 'running' && (
<button
onClick={() => dismiss(task.id)}
className="bg-transparent text-content-faint"
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
aria-label={t('common.close')}
>
<X size={13} />
</button>
)}
</div>
))}
</div>,
document.body
)
}
@@ -1,81 +1,43 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useRef, useEffect } from 'react'
import { Upload, Plane, Train, Hotel, UtensilsCrossed, Car, Anchor, Calendar, ArrowLeft, X } from 'lucide-react'
import type { BookingImportPreviewItem } from '@trek/shared'
import { Upload, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { reservationsApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { reservationsApi, healthApi } from '../../api/client'
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
interface BookingImportModalProps {
isOpen: boolean
onClose: () => void
tripId: number
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
}
const ACCEPTED_EXTS = ['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']
const MAX_FILE_BYTES = 10 * 1024 * 1024
const MAX_FILES = 5
const TYPE_ICONS: Record<string, React.FC<{ size: number; color?: string }>> = {
flight: Plane,
train: Train,
hotel: Hotel,
restaurant: UtensilsCrossed,
car: Car,
cruise: Anchor,
event: Calendar,
}
function typeColor(type: string): string {
const map: Record<string, string> = {
flight: '#3b82f6',
train: '#10b981',
hotel: '#8b5cf6',
restaurant: '#f59e0b',
car: '#6b7280',
cruise: '#06b6d4',
event: '#ec4899',
}
return map[type] ?? 'var(--text-faint)'
}
function formatDateTime(iso: unknown): string {
if (!iso) return ''
const str = typeof iso === 'string' ? iso : typeof iso === 'object' ? JSON.stringify(iso) : String(iso)
const date = str.slice(0, 10)
const time = str.length > 10 ? str.slice(11, 16) : ''
return [date, time].filter(Boolean).join(' ')
}
export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }: BookingImportModalProps) {
/**
* Upload booking files and kick off a BACKGROUND parse. The modal closes at once;
* the parse runs server-side and is tracked by the global BackgroundTasksWidget
* (progress over the WebSocket). When it finishes, the trip page opens the per-item
* review flow — so the user can navigate and keep editing while it works.
*/
export default function BookingImportModal({ isOpen, onClose, tripId }: BookingImportModalProps) {
const { t } = useTranslation()
const toast = useToast()
const loadTrip = useTripStore((s) => s.loadTrip)
const addTask = useBackgroundTasksStore((s) => s.addTask)
const fileInputRef = useRef<HTMLInputElement>(null)
const mouseDownTarget = useRef<EventTarget | null>(null)
type Phase = 'upload' | 'preview' | 'confirming'
const [phase, setPhase] = useState<Phase>('upload')
const [files, setFiles] = useState<File[]>([])
const [isDragOver, setIsDragOver] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [previewItems, setPreviewItems] = useState<BookingImportPreviewItem[]>([])
const [warnings, setWarnings] = useState<string[]>([])
const [excluded, setExcluded] = useState<Set<number>>(() => new Set())
const [aiParsing, setAiParsing] = useState(false)
const reset = () => {
setPhase('upload')
setFiles([])
setIsDragOver(false)
setLoading(false)
setError('')
setPreviewItems([])
setWarnings([])
setExcluded(new Set())
}
useEffect(() => {
@@ -84,6 +46,11 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
useEffect(() => {
if (!isOpen) return
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
}, [isOpen])
const handleClose = () => { reset(); onClose() }
const validateFile = (f: File): string | null => {
@@ -121,88 +88,41 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
if (list.length) selectFiles(list)
}
// Start the parse in the background and close — the widget takes it from here.
const handleParse = async () => {
if (files.length === 0 || loading) return
setLoading(true)
setError('')
try {
const result = await reservationsApi.importBookingPreview(tripId, files)
setPreviewItems(result.items ?? [])
setWarnings(result.warnings ?? [])
setExcluded(new Set())
setPhase('preview')
} catch (err: any) {
const msg = err?.response?.data?.error ?? t('reservations.import.error')
setError(msg)
} finally {
setLoading(false)
}
}
const handleConfirm = async () => {
const toImport = previewItems.filter((_, i) => !excluded.has(i))
if (toImport.length === 0) return
setPhase('confirming')
setError('')
try {
const result = await reservationsApi.importBookingConfirm(tripId, toImport)
const created = result.created ?? []
await loadTrip(tripId)
if (created.length > 0) {
pushUndo?.(t('undo.importBooking'), async () => {
try {
const { reservationsApi: rApi } = await import('../../api/client')
await Promise.all(created.map((r) => rApi.delete(tripId, r.id).catch(() => {})))
} catch {}
await loadTrip(tripId)
})
toast.success(t('reservations.import.success', { count: created.length }))
} else {
toast.warning(t('reservations.import.previewEmpty'))
}
const mode = aiParsing ? 'fallback-on-empty' : 'no-ai'
const { jobId } = await reservationsApi.importBookingAsync(tripId, files, mode)
addTask({ id: jobId, tripId: String(tripId), label: files.map((f) => f.name).join(', '), total: files.length })
handleClose()
} catch (err: any) {
setError(err?.response?.data?.error ?? t('reservations.import.error'))
setPhase('preview')
setLoading(false)
}
}
const toggleExclude = (idx: number) => {
setExcluded(prev => {
const next = new Set(prev)
if (next.has(idx)) next.delete(idx); else next.add(idx)
return next
})
}
const activeCount = previewItems.filter((_, i) => !excluded.has(i)).length
if (!isOpen) return null
return ReactDOM.createPortal(
<div
className="bg-[rgba(0,0,0,0.4)]"
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
onMouseDown={(e) => { mouseDownTarget.current = e.target }}
onClick={(e) => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
mouseDownTarget.current = null
}}
>
<div
onClick={e => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
className="bg-surface-card"
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
{phase === 'preview' && (
<button onClick={() => setPhase('upload')} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
<ArrowLeft size={16} />
</button>
)}
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('reservations.import.title')}
</div>
@@ -212,131 +132,45 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{/* Upload phase */}
{phase === 'upload' && (
<>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
{t('reservations.import.acceptedFormats')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
{t('reservations.import.acceptedFormats')}
</div>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTS.join(',')}
multiple
style={{ display: 'none' }}
onChange={handleInputChange}
/>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTS.join(',')}
multiple
style={{ display: 'none' }}
onChange={handleInputChange}
/>
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
style={{
width: '100%', minHeight: 100, borderRadius: 12,
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
marginBottom: 12, padding: 16, boxSizing: 'border-box',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
{isDragOver ? (
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
) : files.length > 0 ? (
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map(f => f.name).join(', ')}</span>
) : (
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
)}
</div>
</>
)}
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'}
style={{
width: '100%', minHeight: 100, borderRadius: 12,
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer',
marginBottom: 12, padding: 16, boxSizing: 'border-box',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
{isDragOver ? (
<span className="text-accent" style={{ pointerEvents: 'none' }}>{t('reservations.import.dropActive')}</span>
) : files.length > 0 ? (
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{files.map((f) => f.name).join(', ')}</span>
) : (
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('reservations.import.dropHere')}</span>
)}
</div>
{/* Preview phase */}
{(phase === 'preview' || phase === 'confirming') && (
<>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 10 }}>
{t('reservations.import.previewHeading', { count: previewItems.length })}
</div>
{previewItems.length === 0 && (
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
{t('reservations.import.previewEmpty')}
</div>
)}
{previewItems.map((item, idx) => {
const Icon = TYPE_ICONS[item.type] ?? Calendar
const isExcluded = excluded.has(idx)
const fromEp = item.endpoints?.find(e => e.role === 'from')
const toEp = item.endpoints?.find(e => e.role === 'to')
return (
<div
key={`${item.source.fileName}-${idx}`}
className={isExcluded ? 'bg-surface-tertiary' : 'bg-surface-secondary'}
style={{
borderRadius: 10, padding: '10px 12px', marginBottom: 8,
border: `1px solid ${isExcluded ? 'var(--border-faint)' : 'var(--border-primary)'}`,
opacity: isExcluded ? 0.5 : 1, transition: 'opacity 0.15s',
display: 'flex', gap: 10, alignItems: 'flex-start',
}}
>
<div style={{ flexShrink: 0, marginTop: 2 }}>
<Icon size={15} color={typeColor(item.type)} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{item.title}
</div>
{fromEp && toEp && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
{fromEp.code ?? fromEp.name} {toEp.code ?? toEp.name}
</div>
)}
{item.reservation_time && (
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{formatDateTime(item.reservation_time)}
{item.reservation_end_time && ` ${formatDateTime(item.reservation_end_time)}`}
</div>
)}
{item._accommodation?.check_in && (
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
{formatDateTime(item._accommodation.check_in)} {formatDateTime(item._accommodation.check_out)}
</div>
)}
{item.confirmation_number && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', fontFamily: 'monospace' }}>
{item.confirmation_number}
</div>
)}
</div>
<button
onClick={() => toggleExclude(idx)}
className="bg-transparent text-content-faint"
style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, flexShrink: 0, fontSize: 11, fontFamily: 'inherit', fontWeight: 500 }}
title={t('reservations.import.removeItem')}
>
{isExcluded ? '' : <X size={12} />}
</button>
</div>
)
})}
</>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="bg-[rgba(245,158,11,0.08)] text-[#92400e]" style={{ border: '1px solid rgba(245,158,11,0.3)', borderRadius: 10, padding: '8px 10px', fontSize: 12, marginTop: 8, whiteSpace: 'pre-wrap' }}>
{warnings.join('\n')}
</div>
)}
{/* Error */}
{error && (
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
{error}
@@ -352,28 +186,14 @@ export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }
>
{t('common.cancel')}
</button>
{phase === 'upload' && (
<button
onClick={handleParse}
disabled={files.length === 0 || loading}
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{loading ? t('reservations.import.parsing') : t('common.import')}
</button>
)}
{(phase === 'preview' || phase === 'confirming') && (
<button
onClick={handleConfirm}
disabled={activeCount === 0 || phase === 'confirming'}
className={activeCount > 0 && phase !== 'confirming' ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: activeCount > 0 && phase !== 'confirming' ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{phase === 'confirming' ? t('common.loading') : t('reservations.import.confirm', { count: activeCount })}
</button>
)}
<button
onClick={handleParse}
disabled={files.length === 0 || loading}
className={files.length > 0 && !loading ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: files.length > 0 && !loading ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{loading ? t('reservations.import.parsing') : t('common.import')}
</button>
</div>
</div>
</div>,
@@ -14,6 +14,7 @@ import { openFile } from '../../utils/fileDownload'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import { typeToCostCategory } from '@trek/shared'
const TYPE_OPTIONS = [
@@ -64,9 +65,12 @@ interface ReservationModalProps {
accommodations?: Accommodation[]
defaultAssignmentId?: number | null
onOpenExpense?: (req: BookingExpenseRequest) => void
// Pre-fill a brand-new booking from a parsed import item (review-before-save).
// Distinct from `reservation`: the form is populated but stays in create mode.
prefill?: BookingReviewDraft | null
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense }: ReservationModalProps) {
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense, prefill = null }: ReservationModalProps) {
const { id: tripId } = useParams<{ id: string }>()
const loadFiles = useTripStore(s => s.loadFiles)
const toast = useToast()
@@ -84,6 +88,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
hotel_address: '',
})
const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
@@ -97,6 +102,32 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
)
useEffect(() => {
// Resolve an ISO date to a trip day id (exact match, else nearest).
const dayIdForDate = (iso: unknown): string | number => {
if (!iso) return ''
const date = String(iso).slice(0, 10)
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return ''
const exact = days.find(d => d.date === date)
if (exact) return exact.id
let best: string | number = ''
let bestDiff = Infinity
for (const d of days) {
if (!d.date) continue
const diff = Math.abs(new Date(d.date).getTime() - new Date(date).getTime())
if (diff < bestDiff) { bestDiff = diff; best = d.id }
}
return best
}
// Match an existing place by name (exact, then loose contains) for hotels.
const matchPlaceId = (name: string | undefined): string | number => {
const n = (name || '').trim().toLowerCase()
if (!n) return ''
const exact = places.find(p => p.name?.trim().toLowerCase() === n)
if (exact) return exact.id
const loose = places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
return loose?.id ?? ''
}
if (reservation) {
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
const rawEnd = reservation.reservation_end_time || ''
@@ -109,6 +140,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
endDate = rawEnd
endTime = ''
}
const editAcc = accommodations.find(a => a.id == reservation.accommodation_id)
setForm({
title: reservation.title || '',
type: reservation.type || 'other',
@@ -124,21 +156,52 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
hotel_place_id: editAcc?.place_id || '',
hotel_start_day: editAcc?.start_day_id || '',
hotel_end_day: editAcc?.end_day_id || '',
hotel_address: places.find(p => p.id == editAcc?.place_id)?.address || '',
})
} else if (prefill) {
// Review-before-save: populate from a parsed import item, stay in create mode.
const meta = (prefill.metadata && typeof prefill.metadata === 'object' ? prefill.metadata : {}) as Record<string, string>
const rawEnd = typeof prefill.reservation_end_time === 'string' ? prefill.reservation_end_time : ''
let endDate = ''
let endTime = rawEnd
if (rawEnd.includes('T')) { endDate = rawEnd.split('T')[0]; endTime = rawEnd.split('T')[1]?.slice(0, 5) || '' }
else if (/^\d{4}-\d{2}-\d{2}$/.test(rawEnd)) { endDate = rawEnd; endTime = '' }
setForm({
title: prefill.title || '',
type: prefill.type || 'other',
status: prefill.status || 'pending',
reservation_time: typeof prefill.reservation_time === 'string' ? prefill.reservation_time.slice(0, 16) : '',
reservation_end_time: endTime,
end_date: endDate,
location: prefill.location || '',
confirmation_number: prefill.confirmation_number || '',
notes: prefill.notes || '',
assignment_id: defaultAssignmentId ?? '',
accommodation_id: '',
meta_check_in_time: meta.check_in_time || '',
meta_check_in_end_time: meta.check_in_end_time || '',
meta_check_out_time: meta.check_out_time || '',
hotel_place_id: matchPlaceId(prefill._venue?.name || prefill.title),
hotel_start_day: dayIdForDate(prefill._accommodation?.check_in),
hotel_end_day: dayIdForDate(prefill._accommodation?.check_out),
hotel_address: prefill._venue?.address || '',
})
setPendingFiles([])
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_address: '',
})
setPendingFiles([])
}
}, [reservation, isOpen, selectedDayId, defaultAssignmentId])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reservation, prefill, isOpen, selectedDayId, defaultAssignmentId, days, places, accommodations])
// Re-hydrate hotel day range when the accommodations prop arrives after the modal opens
// (race: tripAccommodations fetch may complete after isOpen fires, leaving hotel fields empty)
@@ -194,17 +257,33 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
endpoints: [],
needs_review: false,
}
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
if (form.type === 'hotel' && (form.hotel_start_day || form.hotel_end_day)) {
saveData.create_accommodation = {
place_id: form.hotel_place_id || null,
start_day_id: form.hotel_start_day,
end_day_id: form.hotel_end_day,
// No existing place picked but we have an address/name (e.g. a reviewed
// import) → the save handler geocodes it and creates the place.
venue: (!form.hotel_place_id && (form.hotel_address || form.title))
? { name: form.title, address: form.hotel_address || null }
: null,
// Tolerate a single resolved end of the range (a one-night stay or a date
// that only matched one trip day) so the accommodation is still created.
start_day_id: form.hotel_start_day || form.hotel_end_day,
end_day_id: form.hotel_end_day || form.hotel_start_day,
check_in: form.meta_check_in_time || null,
check_in_end: form.meta_check_in_end_time || null,
check_out: form.meta_check_out_time || null,
confirmation: form.confirmation_number || null,
}
}
// Imported booking → auto-create the linked cost from the parsed price (what the
// old direct import did). Only on create (not edit) and only when there's a price.
if (!reservation && prefill && isBudgetEnabled) {
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
const price = Number(pmeta.price)
if (Number.isFinite(price) && price > 0) {
saveData.create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
}
}
const saved = await onSave(saveData)
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
for (const file of pendingFiles) {
@@ -497,6 +576,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
/>
</div>
</div>
<div>
<label className={labelClass}>{t('reservations.locationAddress')}</label>
<input type="text" value={form.hotel_address} onChange={e => set('hotel_address', e.target.value)}
placeholder={t('reservations.locationPlaceholder')} className={inputClass} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.checkIn')}</label>
@@ -312,7 +312,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
if (!hasEndpoints && meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat + (meta.class ? ` · ${meta.class}` : '') })
if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: `${meta.price}${meta.priceCurrency ? ' ' + meta.priceCurrency : ''}` })
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
if (cells.length === 0) return null
@@ -17,6 +17,7 @@ import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import type { BookingReviewDraft } from './parsedItemToDraft'
import { typeToCostCategory } from '@trek/shared'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
@@ -126,9 +127,12 @@ interface TransportModalProps {
onFileUpload?: (fd: FormData) => Promise<unknown>
onFileDelete?: (fileId: number) => Promise<void>
onOpenExpense?: (req: BookingExpenseRequest) => void
// Pre-fill a brand-new transport booking from a parsed import item (review-
// before-save); like `reservation` for the form but stays in create mode.
prefill?: BookingReviewDraft | null
}
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense }: TransportModalProps) {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
@@ -151,28 +155,53 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// Resolve a trip day from a YYYY-MM-DD string: exact match, else the nearest day so an
// imported booking still lands on one. An imported transport arrives without a day_id
// (only its parsed dates), and without a selected day the save would drop the date and
// store a bare "HH:MM" — see buildTime below.
const dayIdForDate = (dateStr: string | null): number | '' => {
if (!dateStr || days.length === 0) return ''
const exact = days.find(d => d.date === dateStr)
if (exact) return exact.id
const target = new Date(dateStr).getTime()
if (Number.isNaN(target)) return ''
let best = days[0]
let bestDiff = Infinity
for (const d of days) {
const diff = Math.abs(new Date(d.date).getTime() - target)
if (diff < bestDiff) { bestDiff = diff; best = d }
}
return best.id
}
useEffect(() => {
if (!isOpen) return
if (reservation) {
const meta = typeof reservation.metadata === 'string'
? JSON.parse(reservation.metadata || '{}')
: (reservation.metadata || {})
const eps = reservation.endpoints || []
// Edit uses the saved `reservation`; a review-import populates from `prefill`.
// Either way the init reads the same fields — `reservation` still decides
// edit-vs-create at submit time.
const src = (reservation ?? prefill) as Reservation | null
if (src) {
const meta = typeof src.metadata === 'string'
? JSON.parse(src.metadata || '{}')
: (src.metadata || {})
const eps = src.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
const type = (TRANSPORT_TYPES as readonly string[]).includes(reservation.type)
? reservation.type as TransportType
const type = (TRANSPORT_TYPES as readonly string[]).includes(src.type)
? src.type as TransportType
: 'flight'
setForm({
title: reservation.title || '',
title: src.title || '',
type,
status: reservation.status === 'confirmed' ? 'confirmed' : 'pending',
start_day_id: reservation.day_id ?? '',
end_day_id: reservation.end_day_id ?? '',
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
status: src.status === 'confirmed' ? 'confirmed' : 'pending',
// For an edit, keep the saved day; for an imported prefill (no day_id), resolve it
// from the parsed pick-up/return date so the date isn't lost on save.
start_day_id: src.day_id ?? dayIdForDate(splitReservationDateTime(src.reservation_time).date),
end_day_id: src.end_day_id ?? dayIdForDate(splitReservationDateTime(src.reservation_end_time).date),
departure_time: splitReservationDateTime(src.reservation_time).time ?? '',
arrival_time: splitReservationDateTime(src.reservation_end_time).time ?? '',
confirmation_number: src.confirmation_number || '',
notes: src.notes || '',
meta_airline: meta.airline || '',
meta_flight_number: meta.flight_number || '',
meta_train_number: meta.train_number || '',
@@ -180,7 +209,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_seat: meta.seat || '',
})
if (type === 'flight') {
const orderedEps = orderedEndpoints(reservation)
const orderedEps = orderedEndpoints(src)
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
let wps: WaypointForm[]
if (orderedEps.length >= 2) {
@@ -191,9 +220,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const isLast = i === orderedEps.length - 1
return {
airport: airportFromEndpoint(ep),
arrDayId: legInto?.arr_day_id ?? (isLast ? (reservation.end_day_id ?? '') : ''),
arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''),
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (isFirst ? (reservation.day_id ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (isFirst ? (src.day_id ?? '') : ''),
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
@@ -202,15 +231,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
})
} else {
// Legacy flight with no (or partial) endpoints — seed two waypoints.
const dep = emptyWaypoint(reservation.day_id ?? '')
const dep = emptyWaypoint(src.day_id ?? '')
dep.airport = airportFromEndpoint(from)
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
dep.airline = meta.airline ?? ''
dep.flight_number = meta.flight_number ?? ''
dep.seat = meta.seat ?? ''
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
const arr = emptyWaypoint(src.end_day_id ?? src.day_id ?? '')
arr.airport = airportFromEndpoint(to)
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
wps = [dep, arr]
}
setWaypoints(wps)
@@ -224,7 +253,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setToPick({})
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
}
}, [isOpen, reservation, selectedDayId, budgetItems])
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
@@ -328,6 +357,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints,
needs_review: false,
}
// Imported booking → auto-create the linked cost from the parsed price (what the
// old direct import did). Only on create (not edit) and only when there's a price.
if (!reservation && prefill && isBudgetEnabled) {
const pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record<string, unknown>) : {}
const price = Number(pmeta.price)
if (Number.isFinite(price) && price > 0) {
;(payload as Record<string, unknown>).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) }
}
}
const saved = await onSave(payload)
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
for (const file of pendingFiles) {
@@ -0,0 +1,47 @@
import type { BookingImportPreviewItem, Reservation, ReservationEndpoint } from '@trek/shared'
/**
* A pre-fill draft for the reservation/transport edit modals built from a parsed
* booking-import item. Carries the normal reservation fields the modals read for
* their form, plus the import-only `_venue`/`_accommodation` the hotel path needs
* to suggest a place and a day range. It has no `id` — the modal stays in
* "create" mode and the user reviews/edits before it is ever persisted.
*/
export interface BookingReviewDraft extends Omit<Partial<Reservation>, 'metadata' | 'endpoints'> {
/** Type-specific extras (airline, flight_number, check_in_time, price, …) as an object. */
metadata?: Record<string, unknown> | null
endpoints?: ReservationEndpoint[]
/** Parsed venue (auto-created place candidate) — hotel/restaurant/event. */
_venue?: BookingImportPreviewItem['_venue']
/** Parsed check-in/out + confirmation — hotels only. */
_accommodation?: BookingImportPreviewItem['_accommodation']
}
/**
* Map a parsed booking item onto the shape the edit modals pre-fill from. Pure
* (no I/O). Transport items keep their geocoded endpoints; venue/accommodation
* ride along untouched so the hotel modal can match a place by name (or create
* one from the reviewed address on save).
*/
export function parsedItemToDraft(item: BookingImportPreviewItem): BookingReviewDraft {
return {
type: item.type,
title: item.title,
status: 'pending',
reservation_time: item.reservation_time ?? null,
reservation_end_time: item.reservation_end_time ?? null,
location: item.location ?? item._venue?.address ?? item._venue?.name ?? null,
confirmation_number: item.confirmation_number ?? null,
notes: null,
metadata: (item.metadata as Record<string, unknown> | undefined) ?? null,
endpoints: (item.endpoints ?? []) as ReservationEndpoint[],
_venue: item._venue,
_accommodation: item._accommodation,
}
}
/** Transport types route to the TransportModal; everything else to the ReservationModal. */
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'])
export function isTransportItem(item: BookingImportPreviewItem): boolean {
return TRANSPORT_TYPES.has(item.type)
}
+22 -3
View File
@@ -18,6 +18,7 @@ import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal'
import { TransportModal } from '../components/Planner/TransportModal'
import BookingImportModal from '../components/Planner/BookingImportModal'
import { useBackgroundTasksStore } from '../store/backgroundTasksStore'
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
// MemoriesPanel moved to Journey addon
import ReservationsPanel from '../components/Planner/ReservationsPanel'
@@ -195,6 +196,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
@@ -210,6 +212,23 @@ export default function TripPlannerPage(): React.ReactElement | null {
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
} = useTripPlanner()
// Bridge: when a finished background import is sent here for review (the user hit
// "review" in the background widget, on this or any page), open the per-item flow.
const bgTasks = useBackgroundTasksStore((s) => s.tasks)
const dismissBgTask = useBackgroundTasksStore((s) => s.dismiss)
useEffect(() => {
const task = bgTasks.find(
(tk) => tk.tripId === String(tripId) && tk.status === 'done' && tk.reviewRequested && !tk.consumed,
)
if (task && task.items && task.items.length > 0) {
// Hand the items to the review flow and clear the widget entry — once the user
// hit "review", the background card has done its job.
const items = task.items
dismissBgTask(task.id)
startImportReview(items)
}
}, [bgTasks, tripId, startImportReview, dismissBgTask])
const poi = usePoiExplore()
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
@@ -699,8 +718,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
<ReservationModal isOpen={showReservationModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) } }} onSave={async (data) => { const r = await handleSaveReservation(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingReservation} prefill={reservationPrefill} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { if (importReviewActive) { advanceImportReview() } else { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) } }} onSave={async (data) => { const r = await handleSaveTransport(data); if (importReviewActive && r) advanceImportReview(); return r }} reservation={editingTransport} prefill={transportPrefill} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
{bookingExpense && (
<ExpenseModal
tripId={tripId}
@@ -713,7 +732,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }}
/>
)}
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} />
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
<ConfirmDialog
isOpen={!!deletePlaceId}
+89 -1
View File
@@ -7,7 +7,9 @@ import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../../components/Planner/parsedItemToDraft'
import type { BookingImportPreviewItem } from '@trek/shared'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { offlineDb } from '../../db/offlineDb'
import { useAuthStore } from '../../store/authStore'
@@ -158,6 +160,12 @@ export function useTripPlanner() {
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
// Review-before-save import: each parsed item pre-fills the normal edit modal so
// the user checks/fixes it, then saves. A ref drives the queue (no stale closures).
const [reservationPrefill, setReservationPrefill] = useState<BookingReviewDraft | null>(null)
const [transportPrefill, setTransportPrefill] = useState<BookingReviewDraft | null>(null)
const [importReviewActive, setImportReviewActive] = useState(false)
const importQueueRef = useRef<BookingImportPreviewItem[]>([])
// Manual route planning: off by default, toggled from the day-plan footer. Mode
// (driving/walking) is per-session and selects which travel time the connectors show.
const [routeShown, setRouteShown] = useState(false)
@@ -578,6 +586,13 @@ export function useTripPlanner() {
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
try {
// Imported hotel with a reviewed address but no existing place picked: match
// an existing place by name, else geocode the address and create one, then link it.
const acc = (data as Record<string, any>).create_accommodation
if (data.type === 'hotel' && acc && acc.venue && !acc.place_id) {
acc.place_id = (await resolveImportedPlace(acc.venue)) ?? undefined
delete acc.venue
}
if (editingReservation) {
// Don't force a day here. The old code pinned it to the (often empty)
// selected day, which dropped the booking out of the Plan; preserving the
@@ -635,6 +650,78 @@ export function useTripPlanner() {
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
// ── Review-before-save booking import ───────────────────────────────────────
// Match an existing trip place by name, else geocode the reviewed address and
// create one. Returns the place id (or null if even creation failed).
const resolveImportedPlace = async (venue: { name?: string; address?: string | null }): Promise<number | null> => {
const name = (venue.name || '').trim()
const n = name.toLowerCase()
if (n) {
const existing = places.find(p => p.name?.trim().toLowerCase() === n)
?? places.find(p => p.name && (p.name.toLowerCase().includes(n) || n.includes(p.name.toLowerCase())))
if (existing) return existing.id
}
let lat: number | null = null
let lng: number | null = null
let address: string | null = venue.address ?? null
try {
const query = venue.address ? `${name} ${venue.address}`.trim() : name
if (query) {
const res = await mapsApi.search(query)
const hit = res?.places?.[0] as { lat?: number; lng?: number; address?: string } | undefined
if (hit && hit.lat != null && hit.lng != null) {
lat = hit.lat; lng = hit.lng
if (!address && hit.address) address = hit.address
}
}
} catch { /* geocode failure is non-fatal — create the place without coords */ }
try {
const place = await placesApi.create(tripId, { name: name || address || 'Accommodation', lat, lng, address } as never)
return (place as { id?: number })?.id ?? null
} catch { return null }
}
// Open the right edit modal for a parsed item, pre-filled, in create mode.
const openImportItem = (item: BookingImportPreviewItem) => {
const draft = parsedItemToDraft(item)
if (isTransportItem(item)) {
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
setEditingTransport(null); setTransportModalDayId(null)
setTransportPrefill(draft); setShowTransportModal(true)
} else {
setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null)
setEditingReservation(null)
setReservationPrefill(draft); setShowReservationModal(true)
}
}
const startImportReview = (items: BookingImportPreviewItem[]) => {
if (!items.length) return
importQueueRef.current = items.slice(1)
setImportReviewActive(true)
openImportItem(items[0])
}
// Called when a reviewed item's modal closes (saved or skipped): open the next,
// or finish the review session and refresh accommodations.
const advanceImportReview = () => {
const queue = importQueueRef.current
if (queue.length > 0) {
importQueueRef.current = queue.slice(1)
openImportItem(queue[0])
return
}
importQueueRef.current = []
setImportReviewActive(false)
setShowReservationModal(false); setEditingReservation(null); setReservationPrefill(null)
setShowTransportModal(false); setEditingTransport(null); setTransportPrefill(null); setTransportModalDayId(null)
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
// Imported bookings auto-create their linked costs server-side, but the saving client
// suppresses its own budget:created echo (X-Socket-Id) — so reload the budget items here
// to surface those expenses without a manual page refresh.
tripActions.loadBudgetItems?.(tripId)
}
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
// Build placeId → order-number map from the selected day's assignments
@@ -693,6 +780,7 @@ export function useTripPlanner() {
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey,
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
+83
View File
@@ -0,0 +1,83 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { BookingImportPreviewItem } from '@trek/shared'
/**
* Tracks booking-import parses that run in the BACKGROUND (the async endpoint).
* The upload modal closes the moment a parse starts and adds a task here; the
* server pushes import:progress / import:done / import:error over the user's
* WebSocket (which reaches every page), and the global BackgroundTasksWidget
* renders the list. The trip page turns a finished task into the review flow.
*
* Persisted (minimal): the server keeps the job for ~10 min and exposes a status
* endpoint, so a reload mid-parse must NOT drop the widget — we persist the running
* (and finished-but-unreviewed) tasks by id and the widget re-fetches their status
* on mount. We deliberately persist neither the parsed `items` (re-fetched) nor the
* transient review flags (so a reload never auto-reopens the review flow).
*/
export interface BackgroundImportTask {
id: string // server job id
tripId: string
label: string // file name(s) being parsed
status: 'running' | 'done' | 'error'
done: number
total: number
items?: BookingImportPreviewItem[]
warnings?: string[]
error?: string
reviewRequested?: boolean // user clicked "review" — the trip page consumes it
consumed?: boolean // review has been handed to the trip page
}
interface BackgroundTasksState {
tasks: BackgroundImportTask[]
addTask: (task: { id: string; tripId: string; label: string; total: number }) => void
setProgress: (id: string, tripId: string, done: number, total: number) => void
setDone: (id: string, tripId: string, items: BookingImportPreviewItem[], warnings: string[]) => void
setError: (id: string, tripId: string, error: string) => void
requestReview: (id: string) => void
markConsumed: (id: string) => void
dismiss: (id: string) => void
}
export const useBackgroundTasksStore = create<BackgroundTasksState>()(
persist(
(set) => {
/** Update an existing task by id, or insert a fresh one (events can arrive before addTask). */
const upsert = (id: string, tripId: string, patch: Partial<BackgroundImportTask>) =>
set((state) => {
const idx = state.tasks.findIndex((t) => t.id === id)
if (idx === -1) {
const base: BackgroundImportTask = { id, tripId, label: 'Import', status: 'running', done: 0, total: 1 }
return { tasks: [...state.tasks, { ...base, ...patch }] }
}
const tasks = state.tasks.slice()
tasks[idx] = { ...tasks[idx], ...patch }
return { tasks }
})
return {
tasks: [],
addTask: ({ id, tripId, label, total }) => upsert(id, tripId, { label, total, status: 'running', done: 0 }),
setProgress: (id, tripId, done, total) => upsert(id, tripId, { done, total, status: 'running' }),
setDone: (id, tripId, items, warnings) => upsert(id, tripId, { status: 'done', items, warnings, done: items?.length ?? 0 }),
setError: (id, tripId, error) => upsert(id, tripId, { status: 'error', error }),
requestReview: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, reviewRequested: true } : t)) })),
markConsumed: (id) => set((s) => ({ tasks: s.tasks.map((t) => (t.id === id ? { ...t, consumed: true, reviewRequested: false } : t)) })),
dismiss: (id) => set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) })),
}
},
{
name: 'trek.bg-import-tasks',
// Persist only what survives a reload usefully: the job id/trip/label and a coarse
// status. The widget re-fetches each job's real status (and parsed items) on mount,
// so we keep neither the heavy `items`/`warnings` nor the transient review flags —
// that also guarantees a reload never re-opens the review flow on its own.
partialize: (state) => ({
tasks: state.tasks
.filter((t) => !t.consumed && t.status !== 'error')
.map((t) => ({ id: t.id, tripId: t.tripId, label: t.label, status: t.status, done: t.done, total: t.total })),
}),
},
),
)
+7
View File
@@ -120,6 +120,13 @@ export interface Settings {
mapbox_style?: string
mapbox_3d_enabled?: boolean
mapbox_quality_mode?: boolean
// AI booking-import fallback (per-user config; used when the admin has not set
// instance-wide config on the llm_parsing addon). llm_api_key is masked on read.
llm_provider?: 'local' | 'openai' | 'anthropic'
llm_model?: string
llm_base_url?: string
llm_multimodal?: boolean
llm_api_key?: string
}
export interface AssignmentsMap {
+260 -51
View File
@@ -1,12 +1,12 @@
{
"name": "@trek/root",
"version": "3.1.1",
"version": "3.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@trek/root",
"version": "3.1.1",
"version": "3.1.2",
"workspaces": [
"client",
"server",
@@ -24,7 +24,7 @@
},
"client": {
"name": "@trek/client",
"version": "3.1.1",
"version": "3.1.2",
"dependencies": {
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/poppins": "^5.2.7",
@@ -4275,6 +4275,205 @@
"dev": true,
"license": "MIT"
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@@ -5691,8 +5890,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.62.0",
@@ -5706,8 +5904,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.62.0",
@@ -5721,8 +5918,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.0",
@@ -5736,8 +5932,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.62.0",
@@ -5751,8 +5946,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.62.0",
@@ -5766,8 +5960,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.62.0",
@@ -5781,8 +5974,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.62.0",
@@ -5796,8 +5988,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.62.0",
@@ -5811,8 +6002,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.62.0",
@@ -5839,8 +6029,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.62.0",
@@ -5854,8 +6043,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.62.0",
@@ -5869,8 +6057,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.62.0",
@@ -5884,8 +6071,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.62.0",
@@ -5899,8 +6085,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.62.0",
@@ -5914,8 +6099,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.62.0",
@@ -5929,8 +6113,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.62.0",
@@ -5944,8 +6127,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.62.0",
@@ -5972,8 +6154,7 @@
"optional": true,
"os": [
"openbsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.62.0",
@@ -5987,8 +6168,7 @@
"optional": true,
"os": [
"openharmony"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.62.0",
@@ -6002,8 +6182,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.62.0",
@@ -6017,8 +6196,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.62.0",
@@ -6032,8 +6210,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.62.0",
@@ -6047,8 +6224,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
@@ -15155,6 +15331,38 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -20543,7 +20751,7 @@
},
"server": {
"name": "@trek/server",
"version": "3.1.1",
"version": "3.1.2",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"@nestjs/common": "^11.1.24",
@@ -20566,6 +20774,7 @@
"node-cron": "^4.2.1",
"nodemailer": "^9.0.1",
"otplib": "^12.0.1",
"pdf-parse": "^2.4.5",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
@@ -20900,7 +21109,7 @@
},
"shared": {
"name": "@trek/shared",
"version": "3.1.1",
"version": "3.1.2",
"dependencies": {
"isomorphic-dompurify": "^3.15.0",
"zod": "^4.3.6"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "3.1.1",
"version": "3.1.2",
"workspaces": [
"client",
"server",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/server",
"version": "3.1.1",
"version": "3.1.2",
"main": "src/index.ts",
"scripts": {
"start": "node --require tsconfig-paths/register dist/index.js",
@@ -42,6 +42,7 @@
"node-cron": "^4.2.1",
"nodemailer": "^9.0.1",
"otplib": "^12.0.1",
"pdf-parse": "^2.4.5",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
+1
View File
@@ -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];
+1
View File
@@ -104,6 +104,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your self-hosted AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
{ id: 'llm_parsing', name: 'AI Parsing', description: 'LLM fallback for booking imports kitinerary cannot read', type: 'integration', icon: 'Sparkles', enabled: 0, sort_order: 15 },
];
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
@@ -1,6 +1,7 @@
import {
Controller,
Post,
Get,
Body,
Param,
Headers,
@@ -15,7 +16,9 @@ import type { User } from '../../types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { BookingImportService } from './booking-import.service';
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse } from '@trek/shared';
import { ImportJobsService } from './import-jobs.service';
import { bookingImportModeSchema } from '@trek/shared';
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode } from '@trek/shared';
const ACCEPTED_EXTS = new Set(['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt']);
const MAX_FILE_BYTES = 10 * 1024 * 1024;
@@ -29,7 +32,10 @@ const UPLOAD = {
@Controller('api/trips/:tripId/reservations/import')
@UseGuards(JwtAuthGuard)
export class BookingImportController {
constructor(private readonly bookingImport: BookingImportService) {}
constructor(
private readonly bookingImport: BookingImportService,
private readonly importJobs: ImportJobsService,
) {}
private requireTrip(tripId: string, user: User) {
const trip = this.bookingImport.verifyTripAccess(tripId, user.id);
@@ -43,6 +49,31 @@ export class BookingImportController {
}
}
/** Shared validation for both the sync and async import endpoints; returns the parsed mode. */
private validateImport(tripId: string, user: User, files: Express.Multer.File[] | undefined, rawMode?: string): BookingImportMode {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const modeResult = bookingImportModeSchema.safeParse(rawMode ?? 'no-ai');
if (!modeResult.success) throw new HttpException({ error: 'Invalid mode' }, 400);
const mode = modeResult.data;
if (mode === 'force-ai' && !this.bookingImport.aiAvailable(user.id)) {
throw new HttpException({ error: 'AI parsing is not configured' }, 409);
}
if (mode === 'no-ai' && !this.bookingImport.isAvailable()) {
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
}
if (!files || files.length === 0) throw new HttpException({ error: 'No files uploaded' }, 400);
for (const f of files) {
const ext = f.originalname.toLowerCase().slice(f.originalname.lastIndexOf('.'));
if (!ACCEPTED_EXTS.has(ext)) {
throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400);
}
}
return mode;
}
/**
* POST /api/trips/:tripId/reservations/import/booking
* Accepts up to 5 booking confirmation files (EML, PDF, PKPass, HTML, TXT).
@@ -54,28 +85,42 @@ export class BookingImportController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@UploadedFiles() files: Express.Multer.File[] | undefined,
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
@Body('mode') rawMode?: string,
): Promise<BookingImportPreviewResponse> {
const mode = this.validateImport(tripId, user, files, rawMode);
return this.bookingImport.preview(files!, mode, user.id);
}
if (!this.bookingImport.isAvailable()) {
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
}
/**
* POST /api/trips/:tripId/reservations/import/booking/async
* Same input as /booking, but returns a job id immediately and parses in the
* background. Progress + completion are pushed over the user's WebSocket
* (import:progress / import:done / import:error). Lets the upload modal close at
* once and a background widget track the work while the user keeps navigating.
*/
@Post('booking/async')
@UseInterceptors(FilesInterceptor('files', MAX_FILES, UPLOAD))
async previewAsync(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@UploadedFiles() files: Express.Multer.File[] | undefined,
@Body('mode') rawMode?: string,
): Promise<{ jobId: string }> {
const mode = this.validateImport(tripId, user, files, rawMode);
const jobId = this.importJobs.start(tripId, files!, mode, user.id);
return { jobId };
}
if (!files || files.length === 0) {
throw new HttpException({ error: 'No files uploaded' }, 400);
}
// Validate extensions
for (const f of files) {
const ext = f.originalname.toLowerCase().slice(f.originalname.lastIndexOf('.'));
if (!ACCEPTED_EXTS.has(ext)) {
throw new HttpException({ error: `Unsupported file type: ${f.originalname}. Accepted: EML, PDF, PKPass, HTML, TXT` }, 400);
}
}
const result: BookingImportPreviewResponse = await this.bookingImport.preview(files);
return result;
/**
* GET /api/trips/:tripId/reservations/import/jobs/:jobId
* Poll a background import job — recovery path for a client that missed the
* WebSocket push (navigation, reconnect). 404 once the job has expired.
*/
@Get('jobs/:jobId')
async jobStatus(@CurrentUser() user: User, @Param('jobId') jobId: string) {
const job = this.importJobs.get(jobId, user.id);
if (!job) throw new HttpException({ error: 'Job not found' }, 404);
return { status: job.status, done: job.done, total: job.total, result: job.result, error: job.error };
}
/**
@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { BookingImportController } from './booking-import.controller';
import { BookingImportService } from './booking-import.service';
import { ImportJobsService } from './import-jobs.service';
import { KitineraryExtractorService } from './kitinerary-extractor.service';
import { FeaturesController } from './features.controller';
import { LlmParseModule } from '../llm-parse/llm-parse.module';
@Module({
imports: [LlmParseModule],
controllers: [BookingImportController, FeaturesController],
providers: [BookingImportService, KitineraryExtractorService],
providers: [BookingImportService, KitineraryExtractorService, ImportJobsService],
})
export class BookingImportModule {}
@@ -4,30 +4,47 @@ import { checkPermission } from '../../services/permissions';
import { verifyTripAccess } from '../../services/tripAccess';
import { createReservation } from '../../services/reservationService';
import { createPlace } from '../../services/placeService';
import { createBudgetItem } from '../../services/budgetService';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
import { searchNominatim } from '../../services/mapsService';
import { db } from '../../db/database';
import type { User } from '../../types';
import { KitineraryExtractorService } from './kitinerary-extractor.service';
import { LlmParseService } from '../llm-parse/llm-parse.service';
import { mapReservations } from './kitinerary-mapper';
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, Reservation } from '@trek/shared';
import type { ParsedBookingItem } from './kitinerary.types';
import { typeToCostCategory } from '@trek/shared';
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode, BookingImportFileReport, Reservation } from '@trek/shared';
import type { ParsedBookingItem, KiReservation } from './kitinerary.types';
function resolveDayId(tripId: string, iso: string | null | undefined): number | null {
if (!iso) return null;
const date = iso.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
const row = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined;
return row?.id ?? null;
const exact = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined;
if (exact) return exact.id;
// Clamp to the nearest trip day so an out-of-range / unmatched check-in still
// resolves and the accommodation row is inserted.
const nearest = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1').get(tripId, date) as { id: number } | undefined;
return nearest?.id ?? null;
}
@Injectable()
export class BookingImportService {
constructor(private readonly extractor: KitineraryExtractorService) {}
constructor(
private readonly extractor: KitineraryExtractorService,
private readonly llmParse: LlmParseService,
) {}
isAvailable(): boolean {
return this.extractor.isAvailable();
}
/** True when the LLM fallback is enabled and configured for this user. */
aiAvailable(userId: number): boolean {
return this.llmParse.isAvailable(userId);
}
verifyTripAccess(tripId: string, userId: number) {
return verifyTripAccess(tripId, userId);
}
@@ -37,37 +54,69 @@ export class BookingImportService {
}
/**
* Parse uploaded files through kitinerary-extractor and return a preview list.
* Does NOT persist anything.
* Parse uploaded files and return a preview list. Does NOT persist anything.
* Runs kitinerary first; depending on `mode`, falls back to the LLM:
* - no-ai: kitinerary only
* - fallback-on-empty: LLM for files kitinerary returns nothing for
* - force-ai: LLM on every file (kitinerary skipped)
* LLM-derived items are flagged needs_review. Per-file AI usage is reported.
*/
async preview(files: Express.Multer.File[]): Promise<BookingImportPreviewResponse> {
if (!this.extractor.isAvailable()) {
async preview(
files: Express.Multer.File[],
mode: BookingImportMode,
userId: number,
onProgress?: (done: number, total: number, fileName: string) => void,
): Promise<BookingImportPreviewResponse> {
const kitineraryAvailable = this.extractor.isAvailable();
const aiAvailable = this.llmParse.isAvailable(userId);
if (!kitineraryAvailable && !aiAvailable) {
throw new HttpException({ error: 'KItinerary extractor is not available on this server' }, 503);
}
const allItems: ParsedBookingItem[] = [];
const allWarnings: string[] = [];
const fileReports: BookingImportFileReport[] = [];
let processed = 0;
for (const file of files) {
let kiItems;
try {
kiItems = await this.extractor.extract(file.buffer, file.originalname);
} catch (err) {
allWarnings.push(`${file.originalname}: extraction failed — ${err instanceof Error ? err.message : String(err)}`);
continue;
let kiItems: KiReservation[] = [];
let aiUsed = false;
// Stage 1: kitinerary (skipped entirely when forcing AI).
if (mode !== 'force-ai' && kitineraryAvailable) {
try {
kiItems = await this.extractor.extract(file.buffer, file.originalname);
} catch (err) {
allWarnings.push(`${file.originalname}: extraction failed — ${err instanceof Error ? err.message : String(err)}`);
}
}
// Stage 1b: LLM fallback.
const runLlm = aiAvailable && (mode === 'force-ai' || (mode === 'fallback-on-empty' && kiItems.length === 0));
if (runLlm) {
aiUsed = true;
const llm = await this.llmParse.parse({ buffer: file.buffer, originalName: file.originalname }, userId);
kiItems = llm.kiItems;
allWarnings.push(...llm.warnings);
}
fileReports.push({ fileName: file.originalname, aiAvailable, aiUsed });
if (kiItems.length === 0) {
allWarnings.push(`${file.originalname}: no reservations found`);
continue;
} else {
const { items, warnings } = mapReservations(kiItems, file.originalname);
// LLM extraction is less certain than kitinerary — always flag for review.
if (aiUsed) for (const it of items) it.needs_review = true;
allItems.push(...items);
allWarnings.push(...warnings);
}
const { items, warnings } = mapReservations(kiItems, file.originalname);
allItems.push(...items);
allWarnings.push(...warnings);
// Report per-file progress so a background import can drive a live widget.
onProgress?.(++processed, files.length, file.originalname);
}
return { items: allItems, warnings: allWarnings };
return { items: allItems, warnings: allWarnings, files: fileReports };
}
/**
@@ -126,6 +175,28 @@ export class BookingImportService {
broadcast(tripId, 'place:created', { place }, socketId);
}
// Geocode transport endpoints (stations/stops/terminals/rental desks) that
// arrived without coords, so the route draws and map pins appear. The LLM
// and kitinerary rarely supply geo for non-airport endpoints.
if (Array.isArray(reservationData.endpoints)) {
for (const ep of reservationData.endpoints) {
if ((ep.lat == null || ep.lng == null) && ep.name) {
try {
const hit = (await searchNominatim(ep.name))[0];
if (hit?.lat != null && hit?.lng != null) {
ep.lat = hit.lat;
ep.lng = hit.lng;
}
} catch {
// geocoding failure is non-fatal
}
}
}
// Persist only coord'd endpoints (reservation_endpoints needs lat/lng);
// ungeocodable ones still appeared in the preview's From→To.
reservationData.endpoints = reservationData.endpoints.filter((ep) => ep.lat != null && ep.lng != null);
}
// Build create_accommodation for hotel reservations.
// start_day_id / end_day_id are resolved from check-in/out ISO dates so
// the accommodation row is actually inserted (createReservation gates on them).
@@ -154,6 +225,33 @@ export class BookingImportService {
broadcast(tripId, 'accommodation:created', {}, socketId);
}
// Turn an extracted price into a real linked cost (Costs addon), so the
// booking shows up as an expense — not just a price in metadata.
if (isAddonEnabled(ADDON_IDS.BUDGET)) {
const meta =
reservationData.metadata && typeof reservationData.metadata === 'object'
? (reservationData.metadata as Record<string, unknown>)
: null;
const price = meta && meta.price != null ? Number(meta.price) : NaN;
if (Number.isFinite(price) && price > 0) {
try {
const budgetItem = createBudgetItem(tripId, {
category: typeToCostCategory(item.type),
name: item.title,
total_price: price,
currency: meta && typeof meta.priceCurrency === 'string' ? meta.priceCurrency : null,
reservation_id: reservation.id,
});
broadcast(tripId, 'budget:created', { item: budgetItem }, socketId);
} catch (err) {
console.error(
`[booking-import] Failed to create cost for "${item.title}":`,
err instanceof Error ? err.message : err,
);
}
}
}
created.push(reservation);
} catch (err) {
console.error(`[booking-import] Failed to create reservation "${item.title}":`, err instanceof Error ? err.message : err);
@@ -1,5 +1,7 @@
import { Controller, Get } from '@nestjs/common';
import { KitineraryExtractorService } from './kitinerary-extractor.service';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
/** Exposes server feature flags consumed by the frontend to show/hide optional UI. */
@Controller('api/health')
@@ -10,6 +12,9 @@ export class FeaturesController {
features() {
return {
bookingImport: this.extractor.isAvailable(),
// Addon-level flag (per-user config availability is reported per-file in
// the preview response). Drives whether the client shows AI affordances.
aiParsing: isAddonEnabled(ADDON_IDS.LLM_PARSING),
};
}
}
@@ -0,0 +1,85 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { broadcastToUser } from '../../websocket';
import { BookingImportService } from './booking-import.service';
import type { BookingImportMode, BookingImportPreviewResponse } from '@trek/shared';
type JobStatus = 'running' | 'done' | 'error';
interface ImportJob {
id: string;
tripId: string;
userId: number;
status: JobStatus;
done: number;
total: number;
result?: BookingImportPreviewResponse;
error?: string;
createdAt: number;
}
// Keep a finished job around briefly so a client that missed the WebSocket push
// (navigation, reconnect) can still GET its result.
const JOB_TTL_MS = 10 * 60_000;
/**
* Runs a booking-import parse OFF the request: the controller returns a job id
* immediately, the parse continues here, and progress/completion are pushed to the
* user's sockets via `broadcastToUser` (which reaches them on ANY page, not just the
* trip room). This is what lets the upload modal close at once and a background widget
* track the work while the user keeps navigating. The actual parsing is the same
* `BookingImportService.preview` the synchronous endpoint uses.
*/
@Injectable()
export class ImportJobsService {
private readonly jobs = new Map<string, ImportJob>();
/** Tail of each user's job chain — parses run one at a time per user, not all at once. */
private readonly chains = new Map<number, Promise<void>>();
constructor(private readonly bookingImport: BookingImportService) {}
/** Create a job and queue it behind the user's other parses; returns the job id at once. */
start(tripId: string, files: Express.Multer.File[], mode: BookingImportMode, userId: number): string {
const id = randomUUID();
const job: ImportJob = { id, tripId, userId, status: 'running', done: 0, total: files.length, createdAt: Date.now() };
this.jobs.set(id, job);
// Chain onto the user's previous parse so they run sequentially (one CPU-heavy
// inference at a time), while the request returns immediately.
const prev = this.chains.get(userId) ?? Promise.resolve();
const next = prev.then(() => this.run(job, files, mode)).catch(() => {});
this.chains.set(userId, next);
void next.finally(() => {
if (this.chains.get(userId) === next) this.chains.delete(userId);
});
return id;
}
get(id: string, userId: number): ImportJob | undefined {
const job = this.jobs.get(id);
return job && job.userId === userId ? job : undefined;
}
private async run(job: ImportJob, files: Express.Multer.File[], mode: BookingImportMode): Promise<void> {
this.push(job, 'import:progress', { status: 'running', done: 0, total: job.total });
try {
const result = await this.bookingImport.preview(files, mode, job.userId, (done, total, fileName) => {
job.done = done;
this.push(job, 'import:progress', { status: 'running', done, total, fileName });
});
job.status = 'done';
job.result = result;
this.push(job, 'import:done', { result });
} catch (err) {
job.status = 'error';
job.error = err instanceof Error ? err.message : String(err);
this.push(job, 'import:error', { message: job.error });
} finally {
const id = job.id;
setTimeout(() => this.jobs.delete(id), JOB_TTL_MS).unref?.();
}
}
private push(job: ImportJob, type: string, payload: Record<string, unknown>): void {
broadcastToUser(job.userId, { type, jobId: job.id, tripId: job.tripId, ...payload });
}
}
@@ -189,8 +189,9 @@ function mapTrain(r: KiReservation, source: ParsedBookingItem['source']): Parsed
const endpoints: ParsedEndpoint[] = [];
const dc = coords(t.departureStation?.geo);
const ac = coords(t.arrivalStation?.geo);
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
// Push named endpoints even without coords — confirm() geocodes them later.
if (t.departureStation?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate });
if (t.arrivalStation?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, timezone: null, local_time: arrTime, local_date: arrDate });
return {
type: 'train',
@@ -220,10 +221,10 @@ function mapBus(r: KiReservation, source: ParsedBookingItem['source']): ParsedBo
const endpoints: ParsedEndpoint[] = [];
const dc = coords(b.departureBusStop?.geo);
const ac = coords(b.arrivalBusStop?.geo);
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
if (b.departureBusStop?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate });
if (b.arrivalBusStop?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, timezone: null, local_time: arrTime, local_date: arrDate });
return { type: 'train', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, needs_review: endpoints.length < 2, source };
return { type: 'bus', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, metadata: busId ? { bus_number: busId } : undefined, endpoints, needs_review: endpoints.length < 2, source };
}
function mapBoat(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
@@ -240,10 +241,10 @@ function mapBoat(r: KiReservation, source: ParsedBookingItem['source']): ParsedB
const endpoints: ParsedEndpoint[] = [];
const dc = coords(b.departureBoatTerminal?.geo);
const ac = coords(b.arrivalBoatTerminal?.geo);
if (dc) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc.lat, lng: dc.lng, timezone: null, local_time: depTime, local_date: depDate });
if (ac) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac.lat, lng: ac.lng, timezone: null, local_time: arrTime, local_date: arrDate });
if (b.departureBoatTerminal?.name) endpoints.push({ role: 'from', sequence: 0, name: depName, code: null, lat: dc?.lat ?? null, lng: dc?.lng ?? null, timezone: null, local_time: depTime, local_date: depDate });
if (b.arrivalBoatTerminal?.name) endpoints.push({ role: 'to', sequence: 1, name: arrName, code: null, lat: ac?.lat ?? null, lng: ac?.lng ?? null, timezone: null, local_time: arrTime, local_date: arrDate });
return { type: 'cruise', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, source };
return { type: 'cruise', title, reservation_time: toIsoString(b.departureTime), reservation_end_time: toIsoString(b.arrivalTime), confirmation_number: r.reservationNumber ?? null, endpoints, needs_review: endpoints.length < 2, source };
}
function mapLodging(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
@@ -287,10 +288,31 @@ function mapRentalCar(r: KiReservation, source: ParsedBookingItem['source']): Pa
const title = [company, carName].filter(Boolean).join(' — ') || 'Rental Car';
const pickup = r.pickupLocation as KiReservation['pickupLocation'];
const dropoff = r.dropoffLocation as KiReservation['dropoffLocation'];
const pc = coords(pickup?.geo);
const drc = coords(dropoff?.geo);
const venue: ParsedVenue | undefined = pickup?.name ? { name: pickup.name, ...(pc ?? {}), address: formatAddress(pickup.address) ?? undefined } : undefined;
return { type: 'car', title, reservation_time: toIsoString(r.pickupTime), reservation_end_time: toIsoString(r.dropoffTime), confirmation_number: r.reservationNumber ?? null, ...(venue ? { _venue: venue } : {}), source };
// Pickup → return as from/to endpoints (coords optional; confirm() geocodes).
const { date: puDate, time: puTime } = splitIso(r.pickupTime);
const { date: doDate, time: doTime } = splitIso(r.dropoffTime);
const endpoints: ParsedEndpoint[] = [];
if (pickup?.name) endpoints.push({ role: 'from', sequence: 0, name: pickup.name, code: null, lat: pc?.lat ?? null, lng: pc?.lng ?? null, timezone: null, local_time: puTime, local_date: puDate });
if (dropoff?.name) endpoints.push({ role: 'to', sequence: 1, name: dropoff.name, code: null, lat: drc?.lat ?? null, lng: drc?.lng ?? null, timezone: null, local_time: doTime, local_date: doDate });
return {
type: 'car',
title,
reservation_time: toIsoString(r.pickupTime),
reservation_end_time: toIsoString(r.dropoffTime),
confirmation_number: r.reservationNumber ?? null,
location: formatAddress(pickup?.address) ?? pickup?.name ?? null,
...(company ? { metadata: { rental_company: company } } : {}),
endpoints,
needs_review: endpoints.length < 2,
...(venue ? { _venue: venue } : {}),
source,
};
}
function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
@@ -299,15 +321,42 @@ function mapEvent(r: KiReservation, source: ParsedBookingItem['source']): Parsed
const loc = e.location;
const c = coords(loc?.geo);
const venue: ParsedVenue | undefined = loc?.name ? { name: loc.name, ...(c ?? {}), address: formatAddress(loc.address) ?? undefined } : undefined;
const venue: ParsedVenue | undefined = loc?.name ? { name: loc.name, ...(c ?? {}), address: formatAddress(loc.address) ?? undefined, website: loc.url ?? undefined, phone: loc.telephone ?? undefined } : undefined;
return { type: 'event', title: e.name, reservation_time: toIsoString(e.startDate), reservation_end_time: toIsoString(e.endDate), confirmation_number: r.reservationNumber ?? null, location: loc ? (formatAddress(loc.address) ?? loc.name ?? null) : null, ...(venue ? { _venue: venue } : {}), source };
return { type: 'event', title: e.name, reservation_time: toIsoString(e.startDate ?? r.startTime), reservation_end_time: toIsoString(e.endDate ?? r.endTime), confirmation_number: r.reservationNumber ?? null, location: loc ? (formatAddress(loc.address) ?? loc.name ?? null) : null, ...(venue ? { _venue: venue } : {}), source };
}
// ---------------------------------------------------------------------------
// Public
// ---------------------------------------------------------------------------
/** Merge seat/class/platform/price into an item's metadata (type-agnostic).
* Models name these inconsistently and sometimes nest them under reservationFor,
* so check both levels and common aliases. The item's own metadata wins. */
function applyCommonMeta(item: ParsedBookingItem, r: KiReservation): ParsedBookingItem {
const rf = (r.reservationFor && typeof r.reservationFor === 'object' ? r.reservationFor : {}) as Record<string, unknown>;
const pick = (...keys: string[]): unknown => {
for (const k of keys) {
const v = (r as Record<string, unknown>)[k] ?? rf[k];
if (v != null && v !== '') return v;
}
return undefined;
};
const m: Record<string, unknown> = {};
const seat = pick('seat', 'seatNumber');
if (seat != null) m.seat = String(seat);
const cls = pick('class', 'bookingClass', 'fareClass', 'serviceClass', 'seatingType');
if (cls != null) m.class = String(cls);
const platform = pick('platform', 'departurePlatform');
if (platform != null) m.platform = String(platform);
const price = pick('price', 'priceAmount', 'totalPrice', 'total');
if (price != null) m.price = price;
const cur = pick('priceCurrency', 'priceCurrencyISO4217Code', 'currency');
if (cur != null) m.priceCurrency = String(cur);
if (Object.keys(m).length) item.metadata = { ...m, ...(item.metadata ?? {}) };
return item;
}
export function mapReservations(kiItems: KiReservation[], fileName: string): { items: ParsedBookingItem[]; warnings: string[] } {
const items: ParsedBookingItem[] = [];
const warnings: string[] = [];
@@ -331,7 +380,7 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i
group.push(kiItems[++i]);
}
item = group.length > 1 ? mapFlightGroup(group, source) : mapFlight(r, source);
if (item) items.push(item);
if (item) items.push(applyCommonMeta(item, r));
continue;
}
@@ -348,7 +397,7 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i
warnings.push(`Unknown type "${r['@type']}" in ${fileName}[${i}] — skipped`);
}
if (item) items.push(item);
if (item) items.push(applyCommonMeta(item, r));
}
return { items, warnings };
@@ -112,6 +112,8 @@ export interface KiEventVenue {
name?: string;
address?: string | KiAddress;
geo?: KiGeo;
telephone?: string;
url?: string;
}
export interface KiEvent {
@@ -134,6 +136,12 @@ export interface KiReservation {
endTime?: KiDateTimeish;
reservationFor?: Record<string, unknown>;
pickupLocation?: KiEventVenue;
dropoffLocation?: KiEventVenue;
seat?: string;
class?: string;
platform?: string;
price?: number | string;
priceCurrency?: string;
[key: string]: unknown;
}
@@ -143,8 +151,8 @@ export interface ParsedEndpoint {
sequence: number;
name: string;
code: string | null;
lat: number;
lng: number;
lat: number | null;
lng: number | null;
timezone: string | null;
local_time: string | null;
local_date: string | null;
@@ -0,0 +1,85 @@
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
const TIMEOUT_MS = 120_000;
const MAX_TOKENS = 8192;
const ANTHROPIC_VERSION = '2023-06-01';
const TOOL_NAME = 'emit_reservations';
/**
* Anthropic Messages API client. Structured output via forced tool-use: a single
* `emit_reservations` tool whose `input_schema` is the reservations schema, with
* `tool_choice` forcing it — the documented, reliable way to get structured JSON.
* PDFs go as native base64 `document` blocks (Anthropic reads scanned PDFs).
* Raw fetch (no SDK) to match the codebase's HTTP style.
*/
export class AnthropicClient implements LlmExtractionClient {
async extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]> {
const base = (input.baseUrl ?? 'https://api.anthropic.com').replace(/\/+$/, '');
const url = `${base}/v1/messages`;
const content: unknown[] = [];
if (input.file) {
content.push({
type: 'document',
source: { type: 'base64', media_type: input.file.mimeType, data: input.file.data.toString('base64') },
});
}
content.push({
type: 'text',
text: input.text ? `${USER_TEXT}\n\n${input.text}` : USER_TEXT,
});
const body = {
model: input.model,
max_tokens: MAX_TOKENS,
system: input.prompt,
tools: [
{
name: TOOL_NAME,
description: 'Return the travel reservations extracted from the document.',
input_schema: input.jsonSchema,
},
],
tool_choice: { type: 'tool', name: TOOL_NAME },
messages: [{ role: 'user', content }],
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
'content-type': 'application/json',
'x-api-key': input.apiKey ?? '',
'anthropic-version': ANTHROPIC_VERSION,
},
body: JSON.stringify(body),
});
} finally {
clearTimeout(timer);
}
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(`Anthropic request failed (${res.status}): ${detail.slice(0, 300)}`);
}
const data = (await res.json()) as {
stop_reason?: string;
content?: { type: string; name?: string; input?: { reservations?: unknown } }[];
};
if (data.stop_reason === 'refusal') {
throw new Error('Anthropic declined to process this document');
}
const toolUse = data.content?.find(b => b.type === 'tool_use' && b.name === TOOL_NAME);
const reservations = toolUse?.input?.reservations;
return Array.isArray(reservations) ? (reservations as Record<string, unknown>[]) : [];
}
}
const USER_TEXT = 'Extract every travel reservation from the following document as schema.org JSON-LD.';
@@ -0,0 +1,274 @@
/**
* NuExtract adapter for the OpenAI-compatible client.
*
* NuExtract (NuMind) is not an instruct model — it is fine-tuned to fill a JSON
* *template* whose leaf values are type tokens ("verbatim-string", "date-time",
* …). Fed a generic chat instruction it just echoes the schema back, which is
* why a plain prompt produces garbage. Run through Ollama/llama.cpp the template
* has to be embedded INLINE in the user message under a `# Template:` header
* (llama.cpp ignores vLLM's chat_template_kwargs), with temperature 0.
*
* Rather than ask NuExtract for the nested schema.org shape (its template format
* can't express per-@type conditional fields), we give it ONE flat union template
* — its sweet spot — and map the flat result back into the `KiReservation` shape
* the kitinerary mapper consumes, so the whole downstream pipeline is unchanged.
*/
/** Detect a NuExtract model id (e.g. `hf.co/numind/NuExtract-2.0-2B-GGUF`, `nuextract`). */
export function isNuExtractModel(model: string | undefined): boolean {
return !!model && /nuextract/i.test(model);
}
/**
* Flat union template covering every reservation type. NuExtract fills the
* relevant fields and returns the rest as null, so one template serves all docs.
*
* Deliberately flat (a single reservation, not an array). A small NuExtract (the
* 2B) returns an empty result when handed a nested `{ reservations: [ … ] }`
* array-of-objects template, but extracts reliably from a single flat object —
* so this path yields one reservation per document. Multi-segment itineraries
* (round trips) are left to the generic instruct path (qwen/cloud), which the
* system prompt already drives to emit every leg.
*/
export const NUEXTRACT_TEMPLATE = {
type: ['flight', 'train', 'bus', 'ferry', 'car', 'hotel', 'restaurant', 'event'],
name: 'verbatim-string',
booking_reference: 'verbatim-string',
operator: 'verbatim-string',
vehicle_number: 'verbatim-string',
// Departure/arrival double as a rental car's pick-up/return (place + time) — a
// separate pickup_location field only tempted the model to grab a nearby form
// label ("Location Terminal") instead of the actual depot.
from_name: 'verbatim-string',
from_code: 'verbatim-string',
to_name: 'verbatim-string',
to_code: 'verbatim-string',
departure_time: 'date-time',
arrival_time: 'date-time',
address: 'verbatim-string',
checkin_time: 'date-time',
checkout_time: 'date-time',
start_time: 'date-time',
end_time: 'date-time',
telephone: 'verbatim-string',
website: 'verbatim-string',
seat: 'verbatim-string',
travel_class: 'verbatim-string',
platform: 'verbatim-string',
// Verbatim so we parse the localized number ourselves — asking the model for a
// JSON number turns "1.580,22 €" (German thousands/decimal) into 1.49772.
price: 'verbatim-string',
currency: 'verbatim-string',
};
/**
* Build the NuExtract user-turn text: the template (pretty-printed with the
* indent the model cards use) followed by the document, under a `# Template:`
* header. This is the exact inline format the GGUF model cards document.
*/
export function buildNuExtractUserText(documentText: string): string {
return `# Template:\n${JSON.stringify(NUEXTRACT_TEMPLATE, null, 4)}\n${documentText}`;
}
/** NuExtract `type` token → schema.org reservation `@type`. */
const TYPE_MAP: Record<string, string> = {
flight: 'FlightReservation',
train: 'TrainReservation',
bus: 'BusReservation',
ferry: 'BoatReservation',
boat: 'BoatReservation',
cruise: 'BoatReservation',
car: 'RentalCarReservation',
hotel: 'LodgingReservation',
lodging: 'LodgingReservation',
restaurant: 'FoodEstablishmentReservation',
event: 'EventReservation',
};
/** Recursively drop null/undefined/blank leaves and the empty objects/arrays they leave behind. */
function clean(value: unknown): unknown {
if (Array.isArray(value)) {
const arr = value.map(clean).filter((v) => v !== undefined);
return arr.length ? arr : undefined;
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
const c = clean(v);
if (c !== undefined) out[k] = c;
}
return Object.keys(out).length ? out : undefined;
}
if (value === null || value === undefined) return undefined;
if (typeof value === 'string' && value.trim() === '') return undefined;
return value;
}
/**
* Parse a localized money string into a plain number. Handles German
* ("1.580,22 €" → 1580.22) and English ("1,580.22"/"$89.00" → 89) grouping by
* treating the right-most separator as the decimal point. Returns null when there
* is no parseable amount.
*/
function parseAmount(raw: unknown): number | null {
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null;
if (typeof raw !== 'string') return null;
let s = raw.replace(/[^\d.,]/g, '');
if (!s) return null;
const lastComma = s.lastIndexOf(',');
const lastDot = s.lastIndexOf('.');
let decimal: ',' | '.' | null = null;
if (lastComma > -1 && lastDot > -1) {
decimal = lastComma > lastDot ? ',' : '.';
} else if (lastComma > -1) {
// A single comma with ≤2 trailing digits is a decimal point; otherwise grouping.
const parts = s.split(',');
decimal = parts.length === 2 && parts[1].length <= 2 ? ',' : null;
} else if (lastDot > -1) {
const parts = s.split('.');
decimal = parts.length === 2 && parts[1].length <= 2 ? '.' : null;
}
if (decimal) {
const grouping = decimal === ',' ? '.' : ',';
s = s.split(grouping).join('').replace(decimal, '.');
} else {
s = s.replace(/[.,]/g, '');
}
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
/** Resolve an ISO 4217 currency from a symbol or code found in either field. */
function parseCurrency(...candidates: unknown[]): string | undefined {
for (const c of candidates) {
if (typeof c !== 'string') continue;
const s = c.toUpperCase();
if (s.includes('€') || /\bEUR\b/.test(s)) return 'EUR';
if (s.includes('£') || /\bGBP\b/.test(s)) return 'GBP';
if (s.includes('$') || /\bUSD\b/.test(s)) return 'USD';
const iso = s.match(/\b([A-Z]{3})\b/);
if (iso) return iso[1];
}
return undefined;
}
/** A venue's display name, falling back to the address (or a generic label) so a
* lodging/restaurant/event is never silently dropped when the model misses the name. */
function nameOrFallback(x: Record<string, unknown>, fallback: string): string {
const name = typeof x.name === 'string' ? x.name.trim() : '';
if (name) return name;
const address = typeof x.address === 'string' ? x.address.trim() : '';
if (address) return address.split(',')[0].trim();
return fallback;
}
/** Map one flat NuExtract reservation into a schema.org `KiReservation` node (or undefined). */
function buildNode(x: Record<string, unknown>): Record<string, unknown> | undefined {
const atType = TYPE_MAP[String(x.type ?? '').toLowerCase().trim()];
if (!atType) return undefined;
const node: Record<string, unknown> = {
'@type': atType,
reservationNumber: x.booking_reference,
seat: x.seat,
class: x.travel_class,
platform: x.platform,
price: parseAmount(x.price) ?? undefined,
priceCurrency: parseCurrency(x.currency, x.price),
};
switch (atType) {
case 'FlightReservation':
node.reservationFor = {
flightNumber: x.vehicle_number,
airline: x.operator ? { name: x.operator } : undefined,
departureAirport: { iataCode: x.from_code, name: x.from_name },
arrivalAirport: { iataCode: x.to_code, name: x.to_name },
departureTime: x.departure_time,
arrivalTime: x.arrival_time,
};
break;
case 'TrainReservation':
node.reservationFor = {
trainNumber: x.vehicle_number,
departureStation: { name: x.from_name },
arrivalStation: { name: x.to_name },
departureTime: x.departure_time,
arrivalTime: x.arrival_time,
};
break;
case 'BusReservation':
node.reservationFor = {
busNumber: x.vehicle_number,
departureBusStop: { name: x.from_name },
arrivalBusStop: { name: x.to_name },
departureTime: x.departure_time,
arrivalTime: x.arrival_time,
};
break;
case 'BoatReservation':
node.reservationFor = {
name: x.name ?? x.operator,
departureBoatTerminal: { name: x.from_name },
arrivalBoatTerminal: { name: x.to_name },
departureTime: x.departure_time,
arrivalTime: x.arrival_time,
};
break;
case 'LodgingReservation':
node.reservationFor = { name: nameOrFallback(x, 'Accommodation'), address: x.address, telephone: x.telephone, url: x.website };
node.checkinTime = x.checkin_time;
node.checkoutTime = x.checkout_time;
break;
case 'FoodEstablishmentReservation':
node.reservationFor = { name: nameOrFallback(x, 'Restaurant'), address: x.address, telephone: x.telephone, url: x.website };
node.startTime = x.start_time;
node.endTime = x.end_time;
break;
case 'RentalCarReservation':
// Pick-up / return ride the transport from/to fields (see template comment).
node.reservationFor = { name: x.name, rentalCompany: x.operator ? { name: x.operator } : undefined };
node.pickupTime = x.departure_time;
node.dropoffTime = x.arrival_time;
node.pickupLocation = { name: x.from_name, address: x.address };
node.dropoffLocation = { name: x.to_name };
break;
case 'EventReservation':
node.reservationFor = {
name: nameOrFallback(x, 'Event'),
startDate: x.start_time,
endDate: x.end_time,
location: { address: x.address, telephone: x.telephone, url: x.website },
};
node.startTime = x.start_time;
node.endTime = x.end_time;
break;
}
return clean(node) as Record<string, unknown> | undefined;
}
/**
* Convert a parsed NuExtract response into schema.org `KiReservation` nodes.
* Accepts the `{ reservations: [...] }` wrapper the template asks for, a bare
* array, or a single object. Unrecognized/empty entries are dropped.
*/
export function nuExtractToKiReservations(parsed: unknown): Record<string, unknown>[] {
const wrapped = (parsed as { reservations?: unknown })?.reservations;
const list = Array.isArray(wrapped)
? wrapped
: Array.isArray(parsed)
? parsed
: parsed && typeof parsed === 'object'
? [parsed]
: [];
const out: Record<string, unknown>[] = [];
for (const entry of list) {
if (entry && typeof entry === 'object') {
const node = buildNode(entry as Record<string, unknown>);
if (node) out.push(node);
}
}
return out;
}
@@ -0,0 +1,121 @@
import type { LlmExtractionClient, LlmExtractionInput } from '../llm-provider.interface';
import { isNuExtractModel, buildNuExtractUserText, nuExtractToKiReservations } from './nuextract';
// Generous: a local CPU model (Ollama, no GPU) may cold-load several GB and then
// take a few minutes on a longer document before the first token.
const TIMEOUT_MS = 300_000;
const MAX_TOKENS = 4096;
/**
* OpenAI-compatible chat-completions client. Covers both the "openai" cloud
* provider and the "local" provider (Ollama / vLLM / llama.cpp / LM Studio),
* which all expose `POST {baseUrl}/chat/completions`. Native binaries (PDF) are
* sent as an OpenAI `file` content part; text goes as a text part. Uses the
* global fetch (no SDK) to match the codebase's HTTP style.
*
* A NuExtract model (detected by id) takes a different request shape: the JSON
* template inlined in a single user message, no system prompt and no
* `response_format` (see ./nuextract.ts) — that's how the fine-tune expects to
* be driven; the generic instruct path applies to every other model.
*/
export class OpenAiCompatibleClient implements LlmExtractionClient {
async extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]> {
const base = (input.baseUrl ?? 'https://api.openai.com/v1').replace(/\/+$/, '');
const url = `${base}/chat/completions`;
const nuextract = isNuExtractModel(input.model);
const userContent: unknown[] = nuextract
? [{ type: 'text', text: buildNuExtractUserText(input.text ?? '') }]
: [{ type: 'text', text: input.text ? `${USER_TEXT}\n\n${input.text}` : USER_TEXT }];
// Only genuine images go natively (as image_url) — OpenAI-compatible servers
// (notably Ollama) reject `file`/PDF content parts. PDFs reach this client as
// pre-extracted text (see llm-parse.service.ts), never as bytes.
if (!nuextract && input.file && input.file.mimeType.startsWith('image/')) {
const b64 = input.file.data.toString('base64');
userContent.push({
type: 'image_url',
image_url: { url: `data:${input.file.mimeType};base64,${b64}` },
});
}
const body = {
model: input.model,
max_tokens: MAX_TOKENS,
// Extraction is a deterministic task — Ollama defaults to 0.7, which makes
// small models (NuExtract) drop fields or return empty. Pin to 0.
temperature: 0,
// NuExtract wants the template (in the user turn) to be the only instruction
// — a system prompt or a json_schema grammar derails it.
messages: nuextract
? [{ role: 'user', content: userContent }]
: [
{ role: 'system', content: input.prompt },
{ role: 'user', content: userContent },
],
...(nuextract
? {}
: {
response_format: {
type: 'json_schema' as const,
json_schema: { name: 'reservations', schema: input.jsonSchema, strict: false },
},
}),
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
'content-type': 'application/json',
...(input.apiKey ? { authorization: `Bearer ${input.apiKey}` } : {}),
},
body: JSON.stringify(body),
});
} finally {
clearTimeout(timer);
}
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(`LLM request failed (${res.status}): ${detail.slice(0, 300)}`);
}
const data = (await res.json()) as {
choices?: { message?: { content?: string } }[];
};
const content = data.choices?.[0]?.message?.content;
return nuextract ? parseNuExtract(content) : parseReservations(content);
}
}
/** Strip code fences and JSON.parse; `null` on failure. */
function parseJson(content: string | undefined | null): unknown {
if (!content) return null;
const stripped = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
try {
return JSON.parse(stripped);
} catch {
return null;
}
}
/** Parse a NuExtract response and map its flat template output to KiReservation nodes. */
function parseNuExtract(content: string | undefined | null): Record<string, unknown>[] {
return nuExtractToKiReservations(parseJson(content));
}
const USER_TEXT = 'Extract every travel reservation from the following document as schema.org JSON-LD.';
/** Tolerant parse: strip code fences, JSON.parse, pull `reservations`. `[]` on failure. */
function parseReservations(content: string | undefined | null): Record<string, unknown>[] {
const parsed = parseJson(content);
if (Array.isArray(parsed)) return parsed as Record<string, unknown>[];
if (parsed && typeof parsed === 'object' && Array.isArray((parsed as { reservations?: unknown }).reservations)) {
return (parsed as { reservations: Record<string, unknown>[] }).reservations;
}
return [];
}
@@ -0,0 +1,24 @@
import type { LlmExtractionClient } from './llm-provider.interface';
import type { ResolvedLlmConfig } from '../../services/llmConfig';
import { OpenAiCompatibleClient } from './clients/openai-compatible.client';
import { AnthropicClient } from './clients/anthropic.client';
/**
* Pick the provider client for a resolved config.
* - 'anthropic' → Anthropic Messages API client
* - 'openai' | 'local' → OpenAI-compatible client (cloud or local base URL)
*/
export function createLlmClient(config: ResolvedLlmConfig): LlmExtractionClient {
switch (config.provider) {
case 'anthropic':
return new AnthropicClient();
case 'openai':
case 'local':
return new OpenAiCompatibleClient();
// TODO(nuextract): add a NuExtract template adapter here (local vision model
// with its own template-fill API) once the OpenAI-compatible path proves
// insufficient for small local models — see the design seam in the plan.
default:
return new OpenAiCompatibleClient();
}
}
@@ -0,0 +1,55 @@
import { db } from '../../db/database';
import { ADDON_IDS } from '../../addons';
import { isAddonEnabled } from '../../services/adminService';
import { getUserSettings, getDecryptedUserSetting } from '../../services/settingsService';
import { decryptLlmApiKey, LLM_PROVIDERS, type LlmProvider, type ResolvedLlmConfig } from '../../services/llmConfig';
function asProvider(v: unknown): LlmProvider | null {
return typeof v === 'string' && (LLM_PROVIDERS as string[]).includes(v) ? (v as LlmProvider) : null;
}
function readInstanceConfig(): ResolvedLlmConfig | null {
const row = db.prepare('SELECT config FROM addons WHERE id = ?').get(ADDON_IDS.LLM_PARSING) as { config?: string } | undefined;
if (!row?.config) return null;
let cfg: Record<string, unknown>;
try {
cfg = JSON.parse(row.config || '{}');
} catch {
return null;
}
const provider = asProvider(cfg.provider);
const model = typeof cfg.model === 'string' ? cfg.model.trim() : '';
if (!provider || !model) return null;
return {
provider,
model,
baseUrl: typeof cfg.baseUrl === 'string' && cfg.baseUrl.trim() ? cfg.baseUrl.trim() : undefined,
apiKey: decryptLlmApiKey(cfg.apiKey),
multimodal: cfg.multimodal === true,
};
}
function readUserConfig(userId: number): ResolvedLlmConfig | null {
const settings = getUserSettings(userId);
const provider = asProvider(settings.llm_provider);
const model = typeof settings.llm_model === 'string' ? settings.llm_model.trim() : '';
if (!provider || !model) return null;
const apiKey = getDecryptedUserSetting(userId, 'llm_api_key') ?? undefined;
return {
provider,
model,
baseUrl: typeof settings.llm_base_url === 'string' && settings.llm_base_url.trim() ? settings.llm_base_url.trim() : undefined,
apiKey,
multimodal: settings.llm_multimodal === true,
};
}
/**
* Resolve the effective LLM config for a user, gated by the addon.
* Order: addon disabled → null; admin instance config wins; else per-user config;
* else null. This is the single place the API key is decrypted.
*/
export function resolveLlmConfig(userId: number): ResolvedLlmConfig | null {
if (!isAddonEnabled(ADDON_IDS.LLM_PARSING)) return null;
return readInstanceConfig() ?? readUserConfig(userId);
}
@@ -0,0 +1,45 @@
import { Controller, Get, Post, Query, Body, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { LlmLocalService } from './llm-local.service';
/**
* Admin-only management of a local LLM server (Ollama): list installed models and
* pull new ones (e.g. NuExtract). Used by the AI-parsing addon config UI.
*/
@Controller('api/admin/llm/local')
@UseGuards(JwtAuthGuard, AdminGuard)
export class LlmLocalController {
constructor(private readonly local: LlmLocalService) {}
@Get('models')
models(@Query('baseUrl') baseUrl?: string) {
return this.local.listModels(baseUrl);
}
/**
* Stream a model pull. Proxies Ollama's NDJSON progress lines
* ({ status, total?, completed? }) straight to the client, which reads the
* response body to render a progress bar. Uses @Res() to stream manually.
*/
@Post('pull')
async pull(@Body() body: { baseUrl?: string; model?: string }, @Res() res: Response): Promise<void> {
const stream = await this.local.pull(body?.baseUrl, body?.model ?? '');
res.status(200);
res.setHeader('Content-Type', 'application/x-ndjson');
res.setHeader('Cache-Control', 'no-cache');
const reader = stream.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
res.write(Buffer.from(value));
}
} catch {
// Upstream dropped mid-pull — close the response; the client surfaces it.
} finally {
res.end();
}
}
}
@@ -0,0 +1,63 @@
import { Injectable, HttpException } from '@nestjs/common';
/**
* Admin helpers for managing a local OpenAI-compatible LLM server (Ollama).
* Talks to Ollama's *management* API (`/api/tags`, `/api/pull`), which lives at
* the server root — not the `/v1` OpenAI-compatible path the extraction client
* uses. Admin-only (guarded at the controller); the base URL is admin-supplied
* and typically points at a localhost Ollama, so SSRF guarding is intentionally
* not applied (it would block localhost) — we only validate the protocol.
*/
@Injectable()
export class LlmLocalService {
/** Derive the Ollama root from a configured base URL (strip a trailing /v1). */
ollamaRoot(baseUrl: string | undefined): string {
const raw = (baseUrl ?? 'http://localhost:11434').trim();
let url: URL;
try {
url = new URL(raw);
} catch {
throw new HttpException({ error: 'Invalid base URL' }, 400);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new HttpException({ error: 'Base URL must be http(s)' }, 400);
}
return raw.replace(/\/+$/, '').replace(/\/v1$/, '');
}
/** List models already pulled on the local server. */
async listModels(baseUrl: string | undefined): Promise<{ models: { name: string; size: number }[] }> {
const root = this.ollamaRoot(baseUrl);
let res: Response;
try {
res = await fetch(`${root}/api/tags`, { signal: AbortSignal.timeout(10_000) });
} catch {
throw new HttpException({ error: `Could not reach local LLM server at ${root}` }, 502);
}
if (!res.ok) throw new HttpException({ error: `Local LLM server error (${res.status})` }, 502);
const data = (await res.json()) as { models?: { name?: string; size?: number }[] };
const models = (data.models ?? []).map(m => ({ name: m.name ?? '', size: m.size ?? 0 })).filter(m => m.name);
return { models };
}
/**
* Start a streamed pull. Returns the upstream NDJSON body so the controller can
* pipe Ollama's progress lines straight to the client.
*/
async pull(baseUrl: string | undefined, model: string): Promise<ReadableStream<Uint8Array>> {
if (!model?.trim()) throw new HttpException({ error: 'model is required' }, 400);
const root = this.ollamaRoot(baseUrl);
let res: Response;
try {
res = await fetch(`${root}/api/pull`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: model.trim(), stream: true }),
});
} catch {
throw new HttpException({ error: `Could not reach local LLM server at ${root}` }, 502);
}
if (!res.ok || !res.body) throw new HttpException({ error: `Pull failed (${res.status})` }, 502);
return res.body;
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { LlmParseService } from './llm-parse.service';
import { LlmLocalService } from './llm-local.service';
import { LlmLocalController } from './llm-local.controller';
/** Provides the LLM booking-import fallback; imported by BookingImportModule. */
@Module({
controllers: [LlmLocalController],
providers: [LlmParseService, LlmLocalService],
exports: [LlmParseService],
})
export class LlmParseModule {}
@@ -0,0 +1,161 @@
import type { KiReservation } from '../booking-import/kitinerary.types';
import { createLlmClient } from './llm-client.factory';
import { resolveLlmConfig } from './llm-config.resolver';
import { buildSystemPrompt, KI_RESERVATION_JSON_SCHEMA } from './llm-prompt';
import type { LlmExtractionInput } from './llm-provider.interface';
import { isPdf, extractText } from './text-extract';
import { routeExtraction } from './router/extraction-router';
import { Injectable } from '@nestjs/common';
import { kiReservationSchema } from '@trek/shared';
const MIME_BY_EXT: Record<string, string> = {
'.pdf': 'application/pdf',
};
export interface LlmParseResult {
kiItems: KiReservation[];
warnings: string[];
}
/**
* Orchestrates the LLM fallback: resolve config → pick client → build input
* (native bytes vs extracted text by the `multimodal` flag) → call provider →
* validate the response → return schema.org `KiReservation[]` for the shared
* mapper. Never throws for content/provider reasons — degrades to `[]` + a
* warning, mirroring the kitinerary extractor's tolerance.
*/
@Injectable()
export class LlmParseService {
/** True when the addon is enabled AND a usable config resolves for this user. */
isAvailable(userId: number): boolean {
return resolveLlmConfig(userId) !== null;
}
async parse(file: { buffer: Buffer; originalName: string }, userId: number): Promise<LlmParseResult> {
const config = resolveLlmConfig(userId);
if (!config) return { kiItems: [], warnings: ['AI parsing is not configured'] };
const warnings: string[] = [];
const input: LlmExtractionInput = {
prompt: buildSystemPrompt(),
jsonSchema: KI_RESERVATION_JSON_SCHEMA,
model: config.model,
baseUrl: config.baseUrl,
apiKey: config.apiKey,
};
// Native PDF only for Anthropic (its document block reads text AND scans).
// OpenAI-compatible servers (incl. Ollama/NuExtract) can't ingest PDFs/`file`
// parts, so every other provider gets extracted text.
try {
if (config.provider === 'anthropic' && isPdf(file.originalName)) {
input.file = { mimeType: MIME_BY_EXT['.pdf'], data: file.buffer };
console.debug(
`[DEBUG] Extracted (native PDF, ${file.buffer.length} bytes) sent to ${config.provider}: ${file.originalName}`,
);
} else {
input.text = await extractText(file.buffer, file.originalName);
// The local router decomposes the document and extracts one reservation at a
// time, so it tolerates more text than the single-shot path (which had to cap
// at 4000 to fit a small context). Cloud single-shot keeps the tight cap.
const MAX_EXTRACT_CHARS = config.provider === 'local' ? 16000 : 4000;
if (input.text.length > MAX_EXTRACT_CHARS) input.text = input.text.slice(0, MAX_EXTRACT_CHARS);
console.debug(`[DEBUG] Extracted text from ${file.originalName} (${input.text.length} chars):\n`, input.text);
if (!input.text.trim()) {
return {
kiItems: [],
warnings: [`${file.originalName}: no readable text found (a scanned PDF needs a cloud/vision provider)`],
};
}
}
} catch (err) {
return {
kiItems: [],
warnings: [`${file.originalName}: could not read file — ${err instanceof Error ? err.message : String(err)}`],
};
}
// Local provider (Ollama): go through the layered extraction router — vendor
// templates → decompose + grammar-enforced per-reservation extraction → validate
// + repair. Far more reliable on small CPU models than the single-shot path below
// (which stays for cloud providers, whose strong models handle one-shot well).
if (config.provider === 'local' && input.text) {
try {
const routed = await routeExtraction(input.text, {
baseUrl: config.baseUrl ?? 'http://localhost:11434/v1',
model: config.model,
apiKey: config.apiKey,
});
return { kiItems: routed.kiItems, warnings: [...warnings, ...routed.warnings] };
} catch (err) {
return {
kiItems: [],
warnings: [`${file.originalName}: AI parsing failed — ${err instanceof Error ? err.message : String(err)}`],
};
}
}
let raw: Record<string, unknown>[];
try {
raw = await createLlmClient(config).extract(input);
console.debug('[DEBUG] Raw LLM Response: ', raw);
} catch (err) {
return {
kiItems: [],
warnings: [`${file.originalName}: AI parsing failed — ${err instanceof Error ? err.message : String(err)}`],
};
}
const kiItems: KiReservation[] = [];
for (const node of raw) {
const result = kiReservationSchema.safeParse(node);
if (result.success) kiItems.push(normalizeNode(result.data) as unknown as KiReservation);
else warnings.push(`${file.originalName}: skipped an unrecognized AI result`);
}
return { kiItems, warnings };
}
}
/** Root-level keys in the schema.org reservation shape; everything else is trip-specific. */
const ROOT_KEYS = new Set([
'@type',
'reservationNumber',
'checkinTime',
'checkoutTime',
'pickupTime',
'dropoffTime',
'startTime',
'endTime',
'pickupLocation',
'dropoffLocation',
'seat',
'class',
'platform',
'price',
'priceCurrency',
'reservationFor',
]);
/**
* Small models often flatten the type-specific fields (flightNumber, airline,
* departureAirport, …) onto the reservation root instead of nesting them under
* `reservationFor`, which is where the kitinerary mapper reads them. When
* `reservationFor` is missing/empty, fold the non-root keys into it so the
* existing mappers work unchanged.
*/
function normalizeNode(node: Record<string, unknown>): Record<string, unknown> {
const rf = node.reservationFor;
if (rf && typeof rf === 'object' && Object.keys(rf as object).length > 0) return node;
const out: Record<string, unknown> = {};
const reservationFor: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node)) {
if (ROOT_KEYS.has(k)) out[k] = v;
else reservationFor[k] = v;
}
// Nothing to fold (no flattened type fields) — leave the node as-is.
if (Object.keys(reservationFor).length === 0) return node;
out.reservationFor = reservationFor;
return out;
}
+36
View File
@@ -0,0 +1,36 @@
import { KI_RESERVATION_JSON_SCHEMA, KI_RESERVATION_TYPES } from '@trek/shared';
export { KI_RESERVATION_JSON_SCHEMA };
/**
* System instructions telling the model to emit schema.org reservation JSON-LD
* in exactly the shape the kitinerary binary produces — so the result feeds the
* same `mapReservations()` mapper. Pure (no I/O) so it's unit-testable.
*/
export function buildSystemPrompt(): string {
return [
'You extract travel reservations from a document (a booking confirmation, ticket, or itinerary).',
'Return ONLY a JSON object of the form { "reservations": [ ... ] } — no prose, no markdown.',
'Each reservation is a schema.org JSON-LD object whose "@type" is one of:',
KI_RESERVATION_TYPES.map((t) => ` - ${t}`).join('\n'),
'Put the booking/confirmation code in "reservationNumber" on each reservation.',
'All dates/times are plain ISO 8601 local strings, e.g. "2026-06-11T10:00:00" (no timezone wrapper objects).',
'IMPORTANT: nest the type-specific fields INSIDE a "reservationFor" object — do NOT place them at the top level of the reservation.',
'Populate "reservationFor" with the type-specific fields:',
' FlightReservation: { flightNumber, airline:{name,iataCode}, departureAirport:{iataCode,name,geo:{latitude,longitude}}, arrivalAirport:{...}, departureTime, arrivalTime }',
' TrainReservation: { trainNumber, trainName, departureStation:{name,geo}, arrivalStation:{name,geo}, departureTime, arrivalTime }',
' BusReservation: { busNumber, busName, departureBusStop:{name,geo}, arrivalBusStop:{name,geo}, departureTime, arrivalTime }',
' BoatReservation: { name, departureBoatTerminal:{name,geo}, arrivalBoatTerminal:{name,geo}, departureTime, arrivalTime }',
' LodgingReservation: { name, address, geo:{latitude,longitude}, telephone, url } — put check-in/out in root "checkinTime"/"checkoutTime"',
' FoodEstablishmentReservation: { name, address, geo, telephone, url } — put booking time in root "startTime"/"endTime"',
' RentalCarReservation: { name, model, make, rentalCompany:{name} } — put pickup/dropoff times in root "pickupTime"/"dropoffTime", and the pickup AND return stations in root "pickupLocation" and "dropoffLocation", each {name,address,geo:{latitude,longitude}}',
' EventReservation / TouristAttractionVisit: { name, startDate, endDate, location:{name,address,geo,telephone,url} }',
'When present, also include at the reservation ROOT: "seat", "class" (fare/cabin class), "platform" (trains/buses), and the total "price" (a number) with "priceCurrency" (ISO 4217 code, e.g. EUR).',
'Extract EVERY flight/segment in the document, including return legs — a round trip has TWO OR MORE flights, and each row of a flight table is a separate reservation. Do NOT stop after the first.',
"Each flight shares the booking's reservationNumber. Use the date shown for that specific flight as its departureTime; if a flight lists only one date (no separate arrival time), leave arrivalTime null — never reuse another flight's date.",
'If the document contains no recognizable reservation, return { "reservations": [] }.',
].join('\n');
}
/** Short user-turn instruction that accompanies the document content. */
export const USER_INSTRUCTION = 'Extract every travel reservation from the following document as schema.org JSON-LD.';
@@ -0,0 +1,30 @@
/** A single binary file (e.g. a PDF) sent natively to a multimodal provider. */
export interface LlmExtractionFile {
mimeType: string;
data: Buffer;
}
/** Everything a provider client needs to extract reservations from one document. */
export interface LlmExtractionInput {
/** System instructions enumerating the schema.org shape (see llm-prompt.ts). */
prompt: string;
/** JSON Schema describing `{ reservations: KiReservation[] }`. */
jsonSchema: object;
model: string;
baseUrl?: string;
apiKey?: string;
/** Pre-extracted text (text-like files, or text-only-model mode). */
text?: string;
/** Native binary (PDF) for multimodal providers. */
file?: LlmExtractionFile;
}
/**
* A provider client turns one document into raw schema.org reservation objects.
* It returns the parsed `reservations` array (best-effort: `[]` on a malformed or
* empty response, never throwing for content reasons). The caller validates and
* maps via the shared kitinerary mapper.
*/
export interface LlmExtractionClient {
extract(input: LlmExtractionInput): Promise<Record<string, unknown>[]>;
}
@@ -0,0 +1,232 @@
/**
* The extraction router (Schicht 02) — tuned for ONE model call per document.
*
* 0. deterministic vendor templates first (no LLM, instant);
* 1. exactly one grammar-ENFORCED call (Ollama native `format`):
* - flights → a flat ARRAY of legs in a single call (a capable model fills every
* leg at once — far faster than one call per leg);
* - otherwise → one flat single-reservation call, on the FAST model when the type is
* obvious from keywords (the common case), else the strong model with a union schema;
* 2. booking-wide fields (PNR, total price) and the overnight-arrival day are filled
* DETERMINISTICALLY from the text — the model isn't asked to repeat or reason about them.
*
* No per-leg fan-out and no repair round-trips: that 48× call count was the latency that made
* a multi-leg flight take minutes on a CPU host. The flat results map into the kitinerary
* pipeline via the existing `nuExtractToKiReservations` mapper, so nothing downstream changes.
*/
import type { KiReservation } from '../../booking-import/kitinerary.types';
import { nuExtractToKiReservations } from '../clients/nuextract';
import { FLAT_SCHEMA_BY_TYPE, FLIGHTS_ARRAY_SCHEMA, UNION_SINGLE_SCHEMA, type FlatType } from './flat-schemas';
import { extractEnforced } from './ollama-format.client';
import { matchVendorTemplate } from './vendor-templates';
import type { FlatLike } from './validate';
export interface RouterContext {
baseUrl: string;
model: string;
apiKey?: string;
}
const TRANSPORT_TYPES: FlatType[] = ['flight', 'train', 'bus', 'ferry'];
/** Per-type guidance for the single-reservation prompt. */
const TYPE_HINT: Record<FlatType, string> = {
flight: 'flight. vehicle_number = flight number, from_code/to_code = IATA codes, times = full ISO.',
train: 'train. from_name/to_name = stations, vehicle_number = train number, times = full ISO.',
bus: 'bus. from_name/to_name = stops, times = full ISO.',
ferry: 'ferry/cruise. from_name/to_name = terminals/ports, times = full ISO.',
car: 'rental car. from_name = pick-up location, to_name = return location (may differ), departure_time = pick-up, arrival_time = return.',
hotel: 'hotel stay. name = hotel name, checkin_time/checkout_time = full ISO date-time.',
restaurant: 'restaurant booking. name = the restaurant, start_time = the reservation date-time.',
event: 'event/attraction. name = the event, start_time/end_time = full ISO.',
};
/** Keyword → reservation type, so an obvious document skips the costlier union/strong path. */
const TYPE_KEYWORDS: [FlatType, RegExp][] = [
['car', /\b(sixt|europcar|hertz|avis|enterprise|mietwagen|rental\s*car|autovermietung|anmietung|r(?:ü|ue)ckgabe|pick-?up|drop-?off)\b/i],
['hotel', /\b(hotel|check-?in|check-?out|(?:ü|ue)bernachtung|zimmer|room\s*night|lodging|airbnb|b&b|hostel|pension)\b/i],
['train', /\b(deutsche\s*bahn|bahn|train|railway|\bice\b|\bzug\b|gleis|sncf|trenitalia|renfe)\b/i],
['bus', /\b(flixbus|\bbus\b|coach|omnibus)\b/i],
['ferry', /\b(f(?:ä|ae)hre|ferry|cruise|kreuzfahrt)\b/i],
['restaurant', /\b(restaurant|\btisch\b|table\s*for|men(?:ü|u)|gedeck)\b/i],
['event', /\b(ticket|concert|konzert|veranstaltung|eintritt|admission)\b/i],
];
function detectType(text: string): FlatType | null {
for (const [type, re] of TYPE_KEYWORDS) if (re.test(text)) return type;
return null;
}
/** Detect flight numbers (order-preserving, deduped) — also the "is this a flight doc" test. */
export function detectFlightNumbers(text: string): string[] {
const out: string[] = [];
for (const m of text.matchAll(/\b([A-Z]{2})\s?(\d{2,4})\b/g)) {
const fn = `${m[1]}${m[2]}`;
if (!out.includes(fn)) out.push(fn);
}
return out;
}
/**
* The booking/confirmation code, pulled once for the whole document. Covers the German
* "Bestätigungs-Code" (Airbnb) and "Reservation No." (rental brokers) on top of the PNR /
* Buchungsnummer / Confirmation forms. The match is left-most in the text, so a customer
* "Reservation No." that precedes a vendor "Supplier Reference" wins.
*/
export function extractBookingRef(text: string): string | undefined {
// The captured code must contain a digit: real PNRs/booking codes effectively always
// do, while the case-insensitive [A-Z0-9] class would otherwise grab a following prose
// word ("Confirmation\nThank you…" → "Thank") after a bare label.
const m = text.match(
/(?:PNR|Buchungs(?:code|nummer|referenz)|Booking\s*(?:reference|code|number)|Confirmation\s*(?:number|code)?|Reservierungsnummer|Reservation\s*(?:No\.?|Number|Nr\.?)|Best(?:ä|ae)tigungs[-\s]?(?:nummer|code)|Reference)\s*:?\s*((?=[A-Z0-9]*\d)[A-Z0-9]{5,})/i,
);
return m?.[1];
}
/** Currency symbol/code → ISO 4217. */
function normCurrency(s: string): string | undefined {
const u = s.toUpperCase();
if (u.includes('€') || u === 'EUR') return 'EUR';
if (u.includes('$') || u === 'USD') return 'USD';
if (u.includes('£') || u === 'GBP') return 'GBP';
if (/^[A-Z]{3}$/.test(u)) return u;
return undefined;
}
/** The booking total, pulled deterministically (raw amount string + ISO currency). */
export function extractTotalPrice(text: string): { price: string; currency?: string } | null {
const m = text.match(
/(?:Gesamtpreis|Gesamtbetrag|Gesamtsumme|Total(?:\s*(?:price|amount))?|Amount|Summe|Betrag)\s*:?\s*([€$£]?\s*\d[\d.,]*)\s*(EUR|USD|GBP|CHF|€|\$|£)?/i,
);
if (!m) return null;
return { price: m[1].replace(/[€$£\s]/g, ''), currency: normCurrency(m[2] ?? m[1]) };
}
/**
* Derive a transport leg's arrival DATE deterministically: same day as departure, rolled to
* the next day only when the arrival clock time is earlier than departure (an overnight leg).
* The model reads clock times reliably but mishandles the day rollover.
*/
export function fixArrivalDate(flat: FlatLike): FlatLike {
if (!TRANSPORT_TYPES.includes(flat.type)) return flat;
const dep = /(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(String(flat.departure_time ?? ''));
const arr = /(\d{2}:\d{2})/.exec(String(flat.arrival_time ?? ''));
if (!dep || !arr) return flat;
const [, depDate, depTime] = dep;
const arrTime = arr[1];
const d = new Date(`${depDate}T00:00:00Z`);
if (arrTime < depTime) d.setUTCDate(d.getUTCDate() + 1);
flat.arrival_time = `${d.toISOString().slice(0, 10)}T${arrTime}:00`;
return flat;
}
const DATE_FIELDS = ['departure_time', 'arrival_time', 'checkin_time', 'checkout_time', 'start_time', 'end_time'] as const;
/**
* Coerce a date value to ISO 8601. Models occasionally ignore the format instruction and
* emit a natural-language date ("Aug 23 2025 13:30"), which the downstream `splitIso` then
* slices into garbage ("Aug 23 202"). Keep already-ISO values untouched; otherwise parse and
* reformat. (The server runs in UTC, so the components line up.)
*/
function toIso(value: unknown): unknown {
if (typeof value !== 'string' || !value.trim()) return value;
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return value;
const t = Date.parse(value);
if (Number.isNaN(t)) return value;
const d = new Date(t);
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}T${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:00`;
}
/** Normalize every date-ish field on a flat reservation to ISO before mapping. */
function normalizeDates(flat: FlatLike): FlatLike {
for (const f of DATE_FIELDS) if (f in flat) (flat as Record<string, unknown>)[f] = toIso((flat as Record<string, unknown>)[f]);
return flat;
}
/** One enforced call extracting every flight leg as a flat array. */
async function extractFlights(text: string, ctx: RouterContext): Promise<FlatLike[]> {
const system =
'Extract EVERY flight segment in the document (each flight number is one segment; a round trip has the ' +
'outbound AND the return legs). vehicle_number = the flight number, from_code/to_code = 3-letter IATA codes, ' +
"departure_time/arrival_time = full ISO 'YYYY-MM-DDTHH:MM:00' using the date of the section heading each flight is listed under.";
const out = await extractEnforced({ baseUrl: ctx.baseUrl, model: ctx.model, apiKey: ctx.apiKey, system, user: `Document:\n${text}`, schema: FLIGHTS_ARRAY_SCHEMA, numPredict: 900 });
const legs = Array.isArray((out as { flights?: unknown })?.flights) ? (out as { flights: Record<string, unknown>[] }).flights : [];
return legs.map((leg) => fixArrivalDate(normalizeDates({ ...leg, type: 'flight' as FlatType })));
}
/** One enforced call for a single reservation — a type-specific schema when the type is
* obvious from keywords, else a union schema the model fills with the type it picks. */
async function extractSingle(text: string, ctx: RouterContext): Promise<FlatLike> {
const known = detectType(text);
const call = (schema: Record<string, unknown>, hint: string) =>
extractEnforced({
baseUrl: ctx.baseUrl, model: ctx.model, apiKey: ctx.apiKey,
system: `Extract the single reservation from the document into the flat fields. ${hint} Omit any field that is truly absent.`,
user: `Document:\n${text}`,
schema,
});
if (known) {
const out = (await call(FLAT_SCHEMA_BY_TYPE[known], `It is a ${TYPE_HINT[known]}`)) ?? {};
return fixArrivalDate(normalizeDates({ ...out, type: known }));
}
const out = (await call(UNION_SINGLE_SCHEMA, 'Pick the correct "type".')) ?? {};
const type = (typeof out.type === 'string' ? out.type : 'hotel') as FlatType;
return fixArrivalDate(normalizeDates({ ...out, type }));
}
/**
* Run the router on extracted document text and return schema.org KiReservation nodes.
* Returns `[]` (never throws for content reasons) so the caller degrades gracefully.
*/
/**
* Schicht 2 — fill the booking-wide fields the per-reservation extraction doesn't carry:
* the confirmation/PNR and the booking total. Applied to BOTH the deterministic vendor
* results AND the model output, so a vendor template that read the structured fields but
* whose narrow ref/price regex missed still gets the broad doc-wide deterministic value.
* Never overrides a value the source already provided.
*/
function fillBookingWideFields(flats: Array<Record<string, unknown>>, text: string): void {
const ref = extractBookingRef(text);
const total = extractTotalPrice(text);
// A small model sometimes emits an empty string for a price it didn't find, which is
// not `null` — treat blank/whitespace as "no price" so the deterministic total still wins.
const priceMissing = (v: unknown) => v == null || (typeof v === 'string' && v.trim() === '');
flats.forEach((f, i) => {
if (!f.booking_reference && ref) f.booking_reference = ref;
// The total belongs to the booking, so attach it once (the first item).
if (i === 0 && total && priceMissing(f.price)) {
f.price = total.price;
if (f.currency == null) f.currency = total.currency;
}
});
}
export async function routeExtraction(text: string, ctx: RouterContext): Promise<{ kiItems: KiReservation[]; warnings: string[] }> {
const warnings: string[] = [];
// Schicht 0 — deterministic vendor templates (no LLM). Still top-up the booking-wide
// fields so a template misses on the ref/price doesn't drop them when the doc-wide
// deterministic extractor would have found them.
const vendor = matchVendorTemplate(text);
if (vendor && vendor.length > 0) {
fillBookingWideFields(vendor as unknown as Array<Record<string, unknown>>, text);
return { kiItems: nuExtractToKiReservations(vendor) as unknown as KiReservation[], warnings };
}
// Schicht 1 — exactly one model call.
let flats: FlatLike[];
try {
flats = detectFlightNumbers(text).length > 0 ? await extractFlights(text, ctx) : [await extractSingle(text, ctx)];
} catch (err) {
return { kiItems: [], warnings: [`AI parsing failed — ${err instanceof Error ? err.message : String(err)}`] };
}
// Schicht 2 — deterministic booking-wide fields the per-call schema doesn't carry.
fillBookingWideFields(flats as unknown as Array<Record<string, unknown>>, text);
const kiItems = nuExtractToKiReservations(flats as unknown as Record<string, unknown>[]) as unknown as KiReservation[];
return { kiItems, warnings };
}
@@ -0,0 +1,111 @@
/**
* Type-specific FLAT JSON Schemas for the extraction router.
*
* The router drives a local model with a small, flat, single-reservation schema and
* lets Ollama's native `format` parameter constrain sampling to it (grammar-level —
* see ollama-format.client.ts). Two findings shape this:
* - Enforcing the big nested `{reservations:[union of 8 types]}` schema makes small
* local models collapse (grammar compliance falls off a cliff on deep schemas), so
* we never enforce the monolith — only one flat object at a time.
* - A flat schema whose key fields are `required` forces the model to actually fill
* flightNumber / from / to / dates instead of leaving them null, which is the single
* biggest reliability win for a small model.
*
* The flat field names match NUEXTRACT_TEMPLATE so the existing flat→schema.org mapper
* (`nuExtractToKiReservations`) maps the result straight into the kitinerary pipeline.
*/
export type FlatType = 'flight' | 'train' | 'bus' | 'ferry' | 'car' | 'hotel' | 'restaurant' | 'event';
export const FLAT_TYPES: FlatType[] = ['flight', 'train', 'bus', 'ferry', 'car', 'hotel', 'restaurant', 'event'];
type JsonSchema = Record<string, unknown>;
const STR = { type: 'string' } as const;
/** Build a flat object schema from a field list, marking `required` the ones enforcement must guarantee. */
function flat(fields: string[], required: string[]): JsonSchema {
const properties: Record<string, typeof STR> = {};
for (const f of fields) properties[f] = STR;
return { type: 'object', properties, required };
}
/**
* One schema per reservation type. `required` names the fields the model MUST emit;
* everything else is optional. The router knows the type up-front (from the classifier),
* so the type token itself is not part of the extraction schema — it's set afterwards.
*/
export const FLAT_SCHEMA_BY_TYPE: Record<FlatType, JsonSchema> = {
flight: flat(
['booking_reference', 'operator', 'vehicle_number', 'from_code', 'from_name', 'to_code', 'to_name', 'departure_time', 'arrival_time', 'seat', 'travel_class', 'price', 'currency'],
// booking_reference (PNR) is REQUIRED: the mapper groups legs into one booking by
// shared reservationNumber, so a missing PNR would split a round-trip into loose legs.
// Enforcing it makes the small model actually copy it instead of leaving it null.
['vehicle_number', 'from_code', 'to_code', 'departure_time', 'booking_reference'],
),
train: flat(
['booking_reference', 'operator', 'vehicle_number', 'from_name', 'to_name', 'departure_time', 'arrival_time', 'seat', 'travel_class', 'platform', 'price', 'currency'],
['from_name', 'to_name', 'departure_time'],
),
bus: flat(
['booking_reference', 'operator', 'vehicle_number', 'from_name', 'to_name', 'departure_time', 'arrival_time', 'seat', 'price', 'currency'],
['from_name', 'to_name', 'departure_time'],
),
ferry: flat(
['booking_reference', 'operator', 'name', 'from_name', 'to_name', 'departure_time', 'arrival_time', 'price', 'currency'],
['from_name', 'to_name', 'departure_time'],
),
car: flat(
['booking_reference', 'operator', 'name', 'from_name', 'to_name', 'departure_time', 'arrival_time', 'price', 'currency'],
['from_name', 'departure_time', 'arrival_time'],
),
hotel: flat(
['name', 'booking_reference', 'address', 'checkin_time', 'checkout_time', 'telephone', 'website', 'price', 'currency'],
['name', 'checkin_time', 'checkout_time'],
),
restaurant: flat(
['name', 'booking_reference', 'address', 'start_time', 'end_time', 'telephone', 'website', 'price', 'currency'],
['name'],
),
event: flat(
['name', 'booking_reference', 'address', 'start_time', 'end_time', 'telephone', 'website', 'price', 'currency'],
['name'],
),
};
/**
* All flight legs of a document in ONE shot: a flat array. A capable model (7b) fills
* every leg reliably in a single call — far faster than one call per leg — and the
* booking-wide fields (PNR, total price) are recovered deterministically afterwards.
*/
export const FLIGHTS_ARRAY_SCHEMA: JsonSchema = {
type: 'object',
properties: {
flights: {
type: 'array',
items: flat(
['vehicle_number', 'operator', 'from_code', 'from_name', 'to_code', 'to_name', 'departure_time', 'arrival_time', 'seat', 'travel_class'],
['vehicle_number', 'from_code', 'to_code', 'departure_time'],
),
},
},
required: ['flights'],
};
/**
* Single-reservation fallback when the document type isn't obvious from keywords:
* one flat object the model fills, choosing the `type` itself. Used on the strong
* model so the type pick is reliable.
*/
export const UNION_SINGLE_SCHEMA: JsonSchema = {
type: 'object',
properties: {
type: { type: 'string', enum: FLAT_TYPES },
name: STR, booking_reference: STR, operator: STR, vehicle_number: STR,
from_name: STR, from_code: STR, to_name: STR, to_code: STR,
departure_time: STR, arrival_time: STR, address: STR,
checkin_time: STR, checkout_time: STR, start_time: STR, end_time: STR,
telephone: STR, website: STR, price: STR, currency: STR,
},
required: ['type'],
};
@@ -0,0 +1,91 @@
/**
* Minimal Ollama native-API client used by the extraction router.
*
* Why not the OpenAI-compatible `/v1/chat/completions` path the rest of llm-parse uses?
* Ollama's `/v1` endpoint does NOT faithfully honour OpenAI's `response_format:{json_schema,strict}`
* (it's passed through loosely — the schema and `strict` flag are effectively ignored).
* Ollama's OWN `/api/chat` endpoint with a top-level `format: <jsonSchema>` is the path that
* actually compiles the schema to a GBNF grammar and constrains token sampling. That hard
* guarantee — valid, type-correct, all-required-fields JSON — is the router's foundation,
* so the router talks to `/api/chat` directly. (Cloud providers enforce via their own strict
* tool/response_format and keep using the existing clients.)
*/
const TIMEOUT_MS = 300_000;
export interface EnforcedExtractInput {
/** Ollama base URL — accepts the addon's `…/v1` form; the `/v1` suffix is stripped. */
baseUrl: string;
model: string;
system: string;
user: string;
/** JSON Schema the output is constrained to (grammar-level). */
schema: Record<string, unknown>;
apiKey?: string;
numPredict?: number;
/** Context window. 8192 fits a typical multi-section booking; raise for long itineraries. */
numCtx?: number;
}
/** Resolve the native API base from a config base URL that may end in `/v1`. */
export function toNativeBase(baseUrl: string): string {
return baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '');
}
/** Strip code fences and JSON.parse; returns null on failure. */
function parseJson(content: string | undefined | null): unknown {
if (!content) return null;
const stripped = content.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
try {
return JSON.parse(stripped);
} catch {
return null;
}
}
/**
* Run one schema-constrained chat completion against Ollama's native `/api/chat`.
* Returns the parsed JSON object (constrained to `schema`), or null if the request
* failed or produced unparseable output.
*/
export async function extractEnforced(input: EnforcedExtractInput): Promise<Record<string, unknown> | null> {
const url = `${toNativeBase(input.baseUrl)}/api/chat`;
const body = {
model: input.model,
stream: false,
format: input.schema,
// Keep the model resident a while so back-to-back imports don't pay the cold load.
keep_alive: '30m',
options: { temperature: 0, num_predict: input.numPredict ?? 512, num_ctx: input.numCtx ?? 8192 },
messages: [
{ role: 'system', content: input.system },
{ role: 'user', content: input.user },
],
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
'content-type': 'application/json',
...(input.apiKey ? { authorization: `Bearer ${input.apiKey}` } : {}),
},
body: JSON.stringify(body),
});
} finally {
clearTimeout(timer);
}
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(`Ollama /api/chat failed (${res.status}): ${detail.slice(0, 200)}`);
}
const data = (await res.json()) as { message?: { content?: string } };
const parsed = parseJson(data.message?.content);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
}
@@ -0,0 +1,102 @@
/**
* Schicht 2 — semantic validation of an extracted flat reservation.
*
* Constrained decoding guarantees the JSON is structurally valid, but NOT that the
* values make sense. This layer catches the failure modes that actually hurt users —
* a date with no day, a check-out before check-in, a bogus IATA code, a missing
* booking reference — and returns a human-readable problem list. The router feeds that
* list back to the model for ONE targeted repair pass; whatever still fails is left for
* the human (the review-before-save modal, Schicht 3) rather than silently dropped.
*/
import { findByIata } from '../../../services/airportService';
import type { FlatType } from './flat-schemas';
/** A value that contains a full calendar date (YYYY-MM-DD), not just a time. */
function hasFullDate(v: unknown): boolean {
return typeof v === 'string' && /\d{4}-\d{2}-\d{2}/.test(v);
}
/** The YYYY-MM-DD portion, or null. */
function datePart(v: unknown): string | null {
if (typeof v !== 'string') return null;
const m = v.match(/\d{4}-\d{2}-\d{2}/);
return m ? m[0] : null;
}
function looksLikeIata(v: unknown): boolean {
return typeof v === 'string' && /^[A-Za-z]{3}$/.test(v.trim());
}
export interface FlatLike {
type: FlatType;
booking_reference?: string;
vehicle_number?: string;
from_code?: string;
to_code?: string;
from_name?: string;
to_name?: string;
departure_time?: string;
arrival_time?: string;
checkin_time?: string;
checkout_time?: string;
[k: string]: unknown;
}
const TRANSPORT: FlatType[] = ['flight', 'train', 'bus', 'ferry'];
/**
* Return a list of human-readable problems with a flat reservation, suitable for a
* repair prompt. An empty list means it passed. `requireReference` adds a check for a
* missing booking code (bookings almost always carry one — a miss usually means the
* model skipped it, not that it's absent).
*/
export function validateFlat(flat: FlatLike, requireReference = true): string[] {
const problems: string[] = [];
const t = flat.type;
if (requireReference && !str(flat.booking_reference)) {
problems.push('the booking/confirmation reference is missing — copy it from the document');
}
if (TRANSPORT.includes(t)) {
if (!str(flat.from_code) && !str(flat.from_name)) problems.push('missing departure location');
if (!str(flat.to_code) && !str(flat.to_name)) problems.push('missing arrival location');
if (!hasFullDate(flat.departure_time)) {
problems.push("departure_time must be a full date-time (YYYY-MM-DDTHH:MM:00) using THIS segment's date");
}
if (t === 'flight') {
if (!str(flat.vehicle_number)) problems.push('missing flight number');
for (const [label, code] of [['departure', flat.from_code], ['arrival', flat.to_code]] as const) {
if (str(code) && !looksLikeIata(code)) problems.push(`${label} airport code "${String(code)}" is not a 3-letter IATA code`);
else if (looksLikeIata(code) && !findByIata(String(code).toUpperCase())) {
problems.push(`${label} airport code "${String(code).toUpperCase()}" is not a known IATA code — re-check it`);
}
}
}
if (hasFullDate(flat.departure_time) && hasFullDate(flat.arrival_time)) {
if (new Date(flat.arrival_time as string) < new Date(flat.departure_time as string)) {
problems.push('arrival_time is before departure_time — re-read the times');
}
}
}
if (t === 'hotel') {
if (!hasFullDate(flat.checkin_time)) problems.push('checkin_time must be a full date');
if (!hasFullDate(flat.checkout_time)) problems.push('checkout_time must be a full date');
const ci = datePart(flat.checkin_time);
const co = datePart(flat.checkout_time);
if (ci && co && co < ci) problems.push('check-out date is before check-in — re-read both dates');
}
if (t === 'car') {
if (!hasFullDate(flat.departure_time)) problems.push('the pickup date-time (departure_time) must be a full date');
if (!hasFullDate(flat.arrival_time)) problems.push('the return date-time (arrival_time) must be a full date');
}
return problems;
}
function str(v: unknown): boolean {
return typeof v === 'string' && v.trim().length > 0;
}
@@ -0,0 +1,227 @@
/**
* Schicht 0 — deterministic vendor templates.
*
* KItinerary already handles documents with machine-readable data (boarding-pass
* barcodes, UIC rail codes, embedded schema.org JSON-LD) upstream of the LLM. This
* layer extends the deterministic net to a handful of high-volume vendors whose plain
* PDFs carry NO barcode but a stable text layout (Booking.com, Expedia, Airbnb, the big
* airlines, Sixt/Europcar…). A matched template returns a fully-formed result with ZERO
* model inference — instant, free, and 100% repeatable — so the common case never loads
* the CPU. The LLM router only runs for the long tail.
*
* Templates emit the same flat field shape the router uses, so they feed the identical
* `nuExtractToKiReservations` mapper. Each template must be CONSERVATIVE: fire only on an
* unambiguous marker and only emit fields it can read with certainty — a wrong
* deterministic answer is worse than deferring to the model. This file is the seam where
* new vendor extractors are added; it ships with one worked example.
*/
import type { FlatType } from './flat-schemas';
export interface FlatReservation {
type: FlatType;
booking_reference?: string;
operator?: string;
name?: string;
from_name?: string;
to_name?: string;
departure_time?: string;
arrival_time?: string;
address?: string;
checkin_time?: string;
checkout_time?: string;
price?: string;
currency?: string;
[k: string]: unknown;
}
interface VendorTemplate {
name: string;
/** Cheap check: is this that vendor's document at all? */
match(text: string): boolean;
/** Pull the reservation(s); return [] if the layout didn't parse as expected. */
extract(text: string): FlatReservation[];
}
/** Parse a German/EU numeric date + time ("24.12.2026, 10:00" / "24.12.2026 10:00 Uhr") to ISO. */
function deDateTime(text: string): string | null {
const m = text.match(/(\d{2})\.(\d{2})\.(\d{4})(?:[,\s]+(\d{1,2}):(\d{2}))?/);
if (!m) return null;
const [, d, mo, y, h, mi] = m;
return `${y}-${mo}-${d}` + (h ? `T${h.padStart(2, '0')}:${mi}:00` : '');
}
/** German month name/abbreviation → month number (matched on the first three letters). */
const DE_MONTHS: Record<string, number> = {
jan: 1, feb: 2, 'mär': 3, mrz: 3, apr: 4, mai: 5, jun: 6, jul: 7, aug: 8, sep: 9, okt: 10, nov: 11, dez: 12,
};
/** English month name/abbreviation → month number (matched on the first three letters). */
const EN_MONTHS: Record<string, number> = {
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
};
/** Parse a German long-form date ("3. Mai 2026", "27. Aug. 2025") to an ISO date — no time. */
function deLongDate(text: string): string | null {
const m = text.match(/(\d{1,2})\.\s*([A-Za-zäöüÄÖÜ]+)\.?\s+(\d{4})/);
if (!m) return null;
const mo = DE_MONTHS[m[2].slice(0, 3).toLowerCase()];
if (!mo) return null;
return `${m[3]}-${String(mo).padStart(2, '0')}-${m[1].padStart(2, '0')}`;
}
/**
* Parse an English date + optional time to ISO. Tolerates a comma after the day
* ("Aug 5, 2025") and a 12-hour clock ("Aug 23 2025 01:30 PM" → 13:30) as well as the
* plain 24-hour form ("Aug 23 2025 13:30", "Aug 30 2025").
*/
function enDateTime(text: string): string | null {
const m = text.match(/([A-Za-z]{3,})\.?\s+(\d{1,2}),?\s+(\d{4})(?:[,\s]+(\d{1,2}):(\d{2})\s*([AaPp][Mm])?)?/);
if (!m) return null;
const mo = EN_MONTHS[m[1].slice(0, 3).toLowerCase()];
if (!mo) return null;
const date = `${m[3]}-${String(mo).padStart(2, '0')}-${m[2].padStart(2, '0')}`;
if (!m[4]) return date;
let h = parseInt(m[4], 10);
const meridiem = m[6]?.toLowerCase();
if (meridiem === 'pm' && h !== 12) h += 12;
else if (meridiem === 'am' && h === 12) h = 0;
return `${date}T${String(h).padStart(2, '0')}:${m[5]}:00`;
}
/** Symbol/code → ISO 4217 (defaults to EUR for the EU-centric broker vouchers). */
function moneyCurrency(token: string | undefined): string {
if (!token) return 'EUR';
const u = token.toUpperCase();
if (u.includes('€')) return 'EUR';
if (u.includes('$')) return 'USD';
if (u.includes('£')) return 'GBP';
return /^[A-Z]{3}$/.test(u) ? u : 'EUR';
}
/**
* Example: Sixt rental confirmation. Sixt print-PDFs carry no barcode but a stable
* "Reservierungsnummer" + Anmietung/Rückgabe block. Conservative: only fires on the Sixt
* marker, only emits fields it can read unambiguously, and bails to the LLM otherwise.
*/
const sixt: VendorTemplate = {
name: 'sixt-rental',
match: (t) => /\bSIXT\b/i.test(t) && /Reservierungsnummer/i.test(t),
extract: (t) => {
const ref = t.match(/Reservierungsnummer:?\s*([A-Z0-9]{6,})/i)?.[1];
const pickup = t.match(/Anmietung:?\s*(.+)/i)?.[1]?.trim();
const dropoff = t.match(/R(?:ü|ue)ckgabe:?\s*(.+)/i)?.[1]?.trim();
const pickupTime = pickup ? deDateTime(t.slice(t.indexOf(pickup))) : null;
const dropoffTime = dropoff ? deDateTime(t.slice(t.indexOf(dropoff))) : null;
// Need at least a reference and both endpoints with dates to trust the template.
if (!ref || !pickup || !dropoff || !pickupTime || !dropoffTime) return [];
const place = (s: string) => s.replace(/\s*[-]\s*\d{2}\.\d{2}\.\d{4}.*$/, '').trim();
const priceM = t.match(/Gesamtpreis:?\s*([\d.,]+)\s*(EUR|€)/i);
return [
{
type: 'car',
operator: 'SIXT',
booking_reference: ref,
from_name: place(pickup),
to_name: place(dropoff),
departure_time: pickupTime,
arrival_time: dropoffTime,
...(priceM ? { price: priceM[1], currency: 'EUR' } : {}),
},
];
},
};
/**
* Expedia receipt ("Beleg"). Expedia's German confirmation PDFs carry no barcode but a
* stable "Buchungsdetails" block — hotel name, address, Anreise/Abreise — and an
* "Expedia-Reiseplan" number + "Gesamtpreis". The text layer reads these cleanly even
* when the local model misses the address/price, so pull the hotel deterministically.
* (A combined hotel+flight receipt only yields the hotel here — the airline lines carry
* no IATA flight number, which the model can't reliably turn into legs either.)
*/
const expedia: VendorTemplate = {
name: 'expedia-hotel',
match: (t) => /Expedia-Reiseplan/i.test(t) && /Buchungsdetails/i.test(t) && /Anreise/i.test(t),
extract: (t) => {
const ref = t.match(/Expedia-Reiseplan:?\s*(\d{6,})/i)?.[1];
const block = t.match(/Buchungsdetails\s*\n([\s\S]*?)\nAnreise:/i)?.[1];
const checkin = deLongDate(t.match(/Anreise:?\s*([^\n]+)/i)?.[1] ?? '');
const checkout = deLongDate(t.match(/Abreise:?\s*([^\n]+)/i)?.[1] ?? '');
if (!block || !checkin || !checkout) return [];
const lines = block.split('\n').map((s) => s.trim()).filter(Boolean);
const name = lines[0];
if (!name) return [];
const address = lines.slice(1).join(', ') || undefined;
const priceM = t.match(/Gesamtpreis\s*([\d.,]+)\s*€/i);
return [
{
type: 'hotel',
name,
...(ref ? { booking_reference: ref } : {}),
...(address ? { address } : {}),
checkin_time: checkin,
checkout_time: checkout,
...(priceM ? { price: priceM[1], currency: 'EUR' } : {}),
},
];
},
};
/**
* Broker rental-car voucher (vipcars and the like). These print a stable
* "PICK-UP DETAILS / DROP-OFF DETAILS" pair — each followed by the depot name and an
* English "Mon DD YYYY HH:MM" line — plus a "Reservation No." and a "Payment Details"
* total. The model regularly fails the two-column English date, so read it here.
*/
const brokerRental: VendorTemplate = {
name: 'broker-rental-voucher',
match: (t) => /PICK-?UP DETAILS/i.test(t) && /DROP-?OFF DETAILS/i.test(t) && /Reservation\s*No/i.test(t),
extract: (t) => {
const ref = t.match(/Reservation\s*No\.?:?\s*([A-Z0-9]{5,})/i)?.[1];
const block = (label: RegExp) =>
t.match(new RegExp(label.source + String.raw`\s*\n([^\n]+)\n([A-Za-z]{3,}\.?\s+\d{1,2},?\s+\d{4}[^\n]*)`, 'i'));
const pu = block(/PICK-?UP DETAILS/);
const dof = block(/DROP-?OFF DETAILS/);
const puTime = pu ? enDateTime(pu[2]) : null;
const doTime = dof ? enDateTime(dof[2]) : null;
if (!ref || !pu || !dof || !puTime || !doTime) return [];
const company = t
.match(/SUPPLIER DETAILS\s*\n([^\n]+?)(?:\s+Supplier Reference|\n|$)/i)?.[1]
?.trim()
.replace(/\s*\(V\d+\)\s*$/i, ''); // drop the broker's "(V2)" supplier-version tag
// Read the first amount in the "Payment Details" block; accept the currency on either
// side of the number and derive it (don't assume EUR), so non-EUR vouchers still get a price.
const priceM = t.match(
/Payment Details[\s\S]{0,120}?(?:(EUR|USD|GBP|CHF|€|\$|£)\s*([\d.,]+)|([\d.,]+)\s*(EUR|USD|GBP|CHF|€|\$|£))/i,
);
const price = priceM ? priceM[2] ?? priceM[3] : undefined;
return [
{
type: 'car',
...(company ? { operator: company } : {}),
booking_reference: ref,
from_name: pu[1].trim(),
to_name: dof[1].trim(),
departure_time: puTime,
arrival_time: doTime,
...(price ? { price, currency: moneyCurrency(priceM![1] ?? priceM![4]) } : {}),
},
];
},
};
const TEMPLATES: VendorTemplate[] = [sixt, expedia, brokerRental];
/**
* Try each vendor template; return the first match's result, or null when no template
* applies (the router then falls through to the LLM). A template that matches its vendor
* but can't parse the layout returns [] and is skipped.
*/
export function matchVendorTemplate(text: string): FlatReservation[] | null {
for (const t of TEMPLATES) {
if (!t.match(text)) continue;
const result = t.extract(text);
if (result.length > 0) return result;
}
return null;
}
+71
View File
@@ -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(/&nbsp;/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();
}
+24 -3
View File
@@ -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,
+70
View File
@@ -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;
}
+17 -5
View File
@@ -53,10 +53,16 @@ function resolveDayIdFromTime(
if (!time) return null;
const datePart = time.slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return null;
const row = db
const exact = db
.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1')
.get(tripId, datePart) as { id: number } | undefined;
return row?.id ?? null;
if (exact) return exact.id;
// Fallback: clamp to the nearest day in the trip so a booking whose exact date
// has no day row (or sits just outside the span) still lands on a day.
const nearest = db
.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1')
.get(tripId, datePart) as { id: number } | undefined;
return nearest?.id ?? null;
}
function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
@@ -71,9 +77,15 @@ function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
eps.forEach((e, i) => {
insert.run(rid, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
});
// lat/lng are NOT NULL: an imported transport whose pick-up/return (or station/
// stop) couldn't be geocoded reaches here with null coords. Skip those rows rather
// than let the INSERT throw and fail the entire booking save — the dates still live
// on reservation_time/reservation_end_time, so the booking lands on its day either way.
eps
.filter((e) => e.lat != null && e.lng != null)
.forEach((e, i) => {
insert.run(rid, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
});
});
tx(reservationId, endpoints);
}
+29 -3
View File
@@ -1,10 +1,10 @@
import { db } from '../db/database';
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'mapbox_access_token']);
const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'mapbox_access_token', 'llm_api_key']);
// Encrypted keys that are masked (••••••••) when returned to the client.
// Keys not in this set but in ENCRYPTED_SETTING_KEYS are decrypted and returned.
const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token']);
const MASKED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'llm_api_key']);
export const DEFAULTABLE_USER_SETTING_KEYS = [
'temperature_unit',
@@ -22,6 +22,13 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
'mapbox_style',
'mapbox_3d_enabled',
'mapbox_quality_mode',
// Per-user LLM fallback config for booking import (used when the admin has not
// set instance-wide config on the llm_parsing addon). See llmConfig.ts.
'llm_provider',
'llm_model',
'llm_base_url',
'llm_multimodal',
'llm_api_key',
] as const;
type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number];
@@ -31,9 +38,10 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
time_format: ['12h', '24h'],
dark_mode: [true, false, 'light', 'dark', 'auto'],
map_provider: ['leaflet', 'mapbox-gl'],
llm_provider: ['local', 'openai', 'anthropic'],
};
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode']);
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode', 'llm_multimodal']);
function parseValue(raw: string): unknown {
try { return JSON.parse(raw); } catch { return raw; }
@@ -154,3 +162,21 @@ export function bulkUpsertSettings(userId: number, settings: Record<string, unkn
}
return Object.keys(settings).length;
}
/**
* Read a single per-user setting, decrypting it if it's an encrypted key.
* Unlike getUserSettings (which MASKS encrypted keys for the client), this
* returns the plaintext — for server-side use only (e.g. the LLM config
* resolver needs the real API key). Returns null when unset.
*/
export function getDecryptedUserSetting(userId: number, key: string): string | null {
const row = db.prepare('SELECT value FROM settings WHERE user_id = ? AND key = ?').get(userId, key) as { value: string } | undefined;
if (!row || row.value === '' || row.value == null) return null;
if (ENCRYPTED_SETTING_KEYS.has(key)) return decrypt_api_key(row.value);
try {
const parsed = JSON.parse(row.value);
return typeof parsed === 'string' ? parsed : row.value;
} catch {
return row.value;
}
}
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import { BookingImportController } from '../../../../src/nest/booking-import/booking-import.controller';
import type { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service';
import type { User } from '../../../../src/types';
const user = { id: 1, role: 'user' } as User;
const file = (name = 'a.pdf') => ({ originalname: name, buffer: Buffer.from('x') } as Express.Multer.File);
function make(over: Partial<BookingImportService> = {}) {
const svc = {
verifyTripAccess: vi.fn(() => ({ user_id: 1 })),
canEdit: vi.fn(() => true),
isAvailable: vi.fn(() => true),
aiAvailable: vi.fn(() => true),
preview: vi.fn(async () => ({ items: [], warnings: [], files: [] })),
...over,
} as unknown as BookingImportService;
return { c: new BookingImportController(svc), svc };
}
async function status(fn: () => Promise<unknown>): Promise<number> {
try { await fn(); } catch (e) { expect(e).toBeInstanceOf(HttpException); return (e as HttpException).getStatus(); }
throw new Error('expected throw');
}
beforeEach(() => vi.clearAllMocks());
describe('BookingImportController.preview', () => {
it('rejects an invalid mode with 400', async () => {
const { c } = make();
expect(await status(() => c.preview(user, 't1', [file()], 'bogus'))).toBe(400);
});
it('returns 409 for force-ai when AI is not configured', async () => {
const { c } = make({ aiAvailable: vi.fn(() => false) as any });
expect(await status(() => c.preview(user, 't1', [file()], 'force-ai'))).toBe(409);
});
it('returns 503 for no-ai when the extractor is unavailable', async () => {
const { c } = make({ isAvailable: vi.fn(() => false) as any });
expect(await status(() => c.preview(user, 't1', [file()], 'no-ai'))).toBe(503);
});
it('returns 400 when no files are uploaded', async () => {
const { c } = make();
expect(await status(() => c.preview(user, 't1', [], 'no-ai'))).toBe(400);
});
it('passes the parsed mode and user id through to the service', async () => {
const { c, svc } = make();
await c.preview(user, 't1', [file()], 'fallback-on-empty');
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'fallback-on-empty', 1);
});
it('defaults the mode to no-ai when omitted', async () => {
const { c, svc } = make();
await c.preview(user, 't1', [file()], undefined);
expect(svc.preview).toHaveBeenCalledWith([expect.anything()], 'no-ai', 1);
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
// Mock the heavy side-effect imports so the service module loads cleanly; the
// preview() path under test only touches the extractor + llmParse deps.
vi.mock('../../../../src/db/database', () => ({ db: { prepare: vi.fn() }, closeDb: () => {}, reinitialize: () => {} }));
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn() }));
vi.mock('../../../../src/services/permissions', () => ({ checkPermission: vi.fn(() => true) }));
vi.mock('../../../../src/services/tripAccess', () => ({ verifyTripAccess: vi.fn() }));
vi.mock('../../../../src/services/reservationService', () => ({ createReservation: vi.fn() }));
vi.mock('../../../../src/services/placeService', () => ({ createPlace: vi.fn() }));
vi.mock('../../../../src/services/mapsService', () => ({ searchNominatim: vi.fn() }));
import { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service';
const HOTEL_KI = { '@type': 'LodgingReservation', reservationNumber: 'ABC', reservationFor: { name: 'Hotel X' }, checkinTime: '2026-06-11T15:00', checkoutTime: '2026-06-12T11:00' };
const file = (name = 'a.pdf') => ({ buffer: Buffer.from('x'), originalname: name } as any);
function make(opts: { kit?: boolean; ai?: boolean; extract?: any; parse?: any }) {
const extractor = { isAvailable: () => opts.kit ?? false, extract: vi.fn(opts.extract ?? (async () => [])) };
const llmParse = { isAvailable: () => opts.ai ?? false, parse: vi.fn(opts.parse ?? (async () => ({ kiItems: [], warnings: [] }))) };
return { svc: new BookingImportService(extractor as any, llmParse as any), extractor, llmParse };
}
beforeEach(() => vi.clearAllMocks());
describe('BookingImportService.preview', () => {
it('no-ai: maps kitinerary items, does not force needs_review, reports aiUsed:false', async () => {
const { svc, llmParse } = make({ kit: true, ai: false, extract: async () => [HOTEL_KI] });
const res = await svc.preview([file()], 'no-ai', 1);
expect(res.items).toHaveLength(1);
expect(res.items[0].needs_review).toBeFalsy();
expect(res.files).toEqual([{ fileName: 'a.pdf', aiAvailable: false, aiUsed: false }]);
expect(llmParse.parse).not.toHaveBeenCalled();
});
it('throws 503 when neither parser is available', async () => {
const { svc } = make({ kit: false, ai: false });
try {
await svc.preview([file()], 'no-ai', 1);
throw new Error('expected throw');
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
expect((err as HttpException).getStatus()).toBe(503);
}
});
it('fallback-on-empty: runs the LLM when kitinerary finds nothing and flags needs_review', async () => {
const { svc, extractor, llmParse } = make({
kit: true, ai: true,
extract: async () => [],
parse: async () => ({ kiItems: [HOTEL_KI], warnings: [] }),
});
const res = await svc.preview([file()], 'fallback-on-empty', 1);
expect(extractor.extract).toHaveBeenCalled();
expect(llmParse.parse).toHaveBeenCalled();
expect(res.items).toHaveLength(1);
expect(res.items[0].needs_review).toBe(true);
expect(res.files![0]).toEqual({ fileName: 'a.pdf', aiAvailable: true, aiUsed: true });
});
it('fallback-on-empty: skips the LLM when kitinerary already found items', async () => {
const { svc, llmParse } = make({ kit: true, ai: true, extract: async () => [HOTEL_KI] });
const res = await svc.preview([file()], 'fallback-on-empty', 1);
expect(llmParse.parse).not.toHaveBeenCalled();
expect(res.files![0].aiUsed).toBe(false);
});
it('force-ai: skips kitinerary entirely and uses the LLM', async () => {
const { svc, extractor, llmParse } = make({
kit: true, ai: true,
parse: async () => ({ kiItems: [HOTEL_KI], warnings: [] }),
});
const res = await svc.preview([file()], 'force-ai', 1);
expect(extractor.extract).not.toHaveBeenCalled();
expect(llmParse.parse).toHaveBeenCalled();
expect(res.items[0].needs_review).toBe(true);
});
});
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OpenAiCompatibleClient } from '../../../../src/nest/llm-parse/clients/openai-compatible.client';
import { AnthropicClient } from '../../../../src/nest/llm-parse/clients/anthropic.client';
import type { LlmExtractionInput } from '../../../../src/nest/llm-parse/llm-provider.interface';
const baseInput: LlmExtractionInput = {
prompt: 'system',
jsonSchema: { type: 'object' },
model: 'm',
text: 'Flight AB123',
};
function mockFetch(impl: (url: string, init: RequestInit) => Promise<Response> | Response) {
const fn = vi.fn(impl as any);
vi.stubGlobal('fetch', fn);
return fn;
}
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return { ok, status, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response;
}
beforeEach(() => vi.unstubAllGlobals());
describe('OpenAiCompatibleClient', () => {
it('posts to {baseUrl}/chat/completions and returns the reservations array', async () => {
const fetchFn = mockFetch(() =>
jsonResponse({ choices: [{ message: { content: JSON.stringify({ reservations: [{ '@type': 'FlightReservation' }] }) } }] }),
);
const out = await new OpenAiCompatibleClient().extract({ ...baseInput, baseUrl: 'http://localhost:11434/v1/' });
expect(out).toEqual([{ '@type': 'FlightReservation' }]);
expect(fetchFn.mock.calls[0][0]).toBe('http://localhost:11434/v1/chat/completions');
});
it('tolerates code-fenced JSON', async () => {
mockFetch(() =>
jsonResponse({ choices: [{ message: { content: '```json\n{"reservations":[{"@type":"TrainReservation"}]}\n```' } }] }),
);
const out = await new OpenAiCompatibleClient().extract(baseInput);
expect(out).toEqual([{ '@type': 'TrainReservation' }]);
});
it('returns [] on malformed content', async () => {
mockFetch(() => jsonResponse({ choices: [{ message: { content: 'not json' } }] }));
expect(await new OpenAiCompatibleClient().extract(baseInput)).toEqual([]);
});
it('throws on non-2xx', async () => {
mockFetch(() => jsonResponse({ error: 'bad' }, false, 401));
await expect(new OpenAiCompatibleClient().extract(baseInput)).rejects.toThrow(/401/);
});
it('sends an image natively as image_url but never a file/pdf part', async () => {
const fetchFn = mockFetch(() => jsonResponse({ choices: [{ message: { content: '{"reservations":[]}' } }] }));
await new OpenAiCompatibleClient().extract({ ...baseInput, file: { mimeType: 'image/png', data: Buffer.from('IMG') } });
let parts = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string).messages[1].content;
expect(parts.some((p: any) => p.type === 'image_url')).toBe(true);
expect(parts.some((p: any) => p.type === 'file')).toBe(false);
// A PDF must NOT be sent as a content part (Ollama rejects it).
await new OpenAiCompatibleClient().extract({ ...baseInput, file: { mimeType: 'application/pdf', data: Buffer.from('PDF') } });
parts = JSON.parse((fetchFn.mock.calls[1][1] as RequestInit).body as string).messages[1].content;
expect(parts.every((p: any) => p.type !== 'file' && p.type !== 'image_url')).toBe(true);
});
});
describe('OpenAiCompatibleClient — NuExtract path', () => {
it('inlines the template in one user message (no system, no response_format) and maps the flat result', async () => {
const fetchFn = mockFetch(() =>
jsonResponse({
choices: [
{
message: {
content: JSON.stringify({
reservations: [
{ type: 'hotel', name: 'B&B Hotel', booking_reference: '733', checkin_time: '2026-05-01T15:00:00', checkout_time: '2026-05-02T12:00:00' },
],
}),
},
},
],
}),
);
const out = await new OpenAiCompatibleClient().extract({ ...baseInput, model: 'hf.co/numind/NuExtract-2.0-2B-GGUF:latest', text: 'Hotel doc' });
expect(out).toEqual([
{
'@type': 'LodgingReservation',
reservationNumber: '733',
reservationFor: { name: 'B&B Hotel' },
checkinTime: '2026-05-01T15:00:00',
checkoutTime: '2026-05-02T12:00:00',
},
]);
const body = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string);
expect(body.messages).toHaveLength(1);
expect(body.messages[0].role).toBe('user');
expect(body.messages[0].content[0].text.startsWith('# Template:')).toBe(true);
expect(body.messages[0].content[0].text.endsWith('Hotel doc')).toBe(true);
expect(body.temperature).toBe(0);
expect(body.response_format).toBeUndefined();
});
it('keeps the system prompt and response_format for non-NuExtract models', async () => {
const fetchFn = mockFetch(() => jsonResponse({ choices: [{ message: { content: '{"reservations":[]}' } }] }));
await new OpenAiCompatibleClient().extract({ ...baseInput, model: 'qwen2.5:7b' });
const body = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string);
expect(body.messages[0].role).toBe('system');
expect(body.response_format).toBeDefined();
});
});
describe('AnthropicClient', () => {
it('forces the emit_reservations tool and reads its input', async () => {
const fetchFn = mockFetch(() =>
jsonResponse({ stop_reason: 'tool_use', content: [{ type: 'tool_use', name: 'emit_reservations', input: { reservations: [{ '@type': 'LodgingReservation' }] } }] }),
);
const out = await new AnthropicClient().extract(baseInput);
expect(out).toEqual([{ '@type': 'LodgingReservation' }]);
const body = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string);
expect(body.tool_choice).toEqual({ type: 'tool', name: 'emit_reservations' });
expect(body.tools[0].name).toBe('emit_reservations');
});
it('throws on a refusal stop_reason', async () => {
mockFetch(() => jsonResponse({ stop_reason: 'refusal', content: [] }));
await expect(new AnthropicClient().extract(baseInput)).rejects.toThrow(/declined/i);
});
it('throws on non-2xx', async () => {
mockFetch(() => jsonResponse({ error: 'bad' }, false, 500));
await expect(new AnthropicClient().extract(baseInput)).rejects.toThrow(/500/);
});
it('sends a native pdf as a base64 document block', async () => {
const fetchFn = mockFetch(() => jsonResponse({ content: [{ type: 'tool_use', name: 'emit_reservations', input: { reservations: [] } }] }));
await new AnthropicClient().extract({ ...baseInput, file: { mimeType: 'application/pdf', data: Buffer.from('PDF') } });
const body = JSON.parse((fetchFn.mock.calls[0][1] as RequestInit).body as string);
const blocks = body.messages[0].content;
expect(blocks.some((b: any) => b.type === 'document' && b.source.type === 'base64')).toBe(true);
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { dbMock } = vi.hoisted(() => {
const stmt = { get: vi.fn() };
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
});
vi.mock('../../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn() }));
vi.mock('../../../../src/services/adminService', () => ({ isAddonEnabled }));
const { getUserSettings, getDecryptedUserSetting } = vi.hoisted(() => ({
getUserSettings: vi.fn(() => ({}) as Record<string, unknown>),
getDecryptedUserSetting: vi.fn(() => null as string | null),
}));
vi.mock('../../../../src/services/settingsService', () => ({ getUserSettings, getDecryptedUserSetting }));
import { resolveLlmConfig } from '../../../../src/nest/llm-parse/llm-config.resolver';
function setInstanceConfig(config: unknown) {
dbMock._stmt.get.mockReturnValue(config === undefined ? undefined : { config: JSON.stringify(config) });
}
beforeEach(() => {
vi.clearAllMocks();
isAddonEnabled.mockReturnValue(true);
setInstanceConfig(undefined);
getUserSettings.mockReturnValue({});
getDecryptedUserSetting.mockReturnValue(null);
});
describe('resolveLlmConfig', () => {
it('returns null when the addon is disabled', () => {
isAddonEnabled.mockReturnValue(false);
expect(resolveLlmConfig(1)).toBeNull();
});
it('uses instance config when present (and decrypts the key)', () => {
setInstanceConfig({ provider: 'anthropic', model: 'claude-opus-4-8', apiKey: 'sk-plain', multimodal: true });
expect(resolveLlmConfig(1)).toEqual({
provider: 'anthropic',
model: 'claude-opus-4-8',
baseUrl: undefined,
apiKey: 'sk-plain',
multimodal: true,
});
});
it('falls back to per-user config when instance config is incomplete', () => {
setInstanceConfig({ provider: 'anthropic' }); // no model → not usable
getUserSettings.mockReturnValue({ llm_provider: 'local', llm_model: 'nuextract', llm_base_url: 'http://x/v1', llm_multimodal: true });
getDecryptedUserSetting.mockReturnValue('user-key');
expect(resolveLlmConfig(7)).toEqual({
provider: 'local',
model: 'nuextract',
baseUrl: 'http://x/v1',
apiKey: 'user-key',
multimodal: true,
});
expect(getDecryptedUserSetting).toHaveBeenCalledWith(7, 'llm_api_key');
});
it('returns null when neither instance nor user config is usable', () => {
getUserSettings.mockReturnValue({ llm_provider: 'openai' }); // no model
expect(resolveLlmConfig(1)).toBeNull();
});
});
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HttpException } from '@nestjs/common';
import { LlmLocalService } from '../../../../src/nest/llm-parse/llm-local.service';
const svc = () => new LlmLocalService();
function mockFetch(impl: any) {
const fn = vi.fn(impl);
vi.stubGlobal('fetch', fn);
return fn;
}
beforeEach(() => vi.unstubAllGlobals());
describe('LlmLocalService.ollamaRoot', () => {
it('strips a trailing /v1 and slashes', () => {
expect(svc().ollamaRoot('http://localhost:11434/v1')).toBe('http://localhost:11434');
expect(svc().ollamaRoot('http://localhost:11434/v1/')).toBe('http://localhost:11434');
expect(svc().ollamaRoot('http://host:1/')).toBe('http://host:1');
});
it('defaults when no base URL is given', () => {
expect(svc().ollamaRoot(undefined)).toBe('http://localhost:11434');
});
it('rejects non-http(s) and invalid URLs', () => {
expect(() => svc().ollamaRoot('ftp://x')).toThrow(HttpException);
expect(() => svc().ollamaRoot('not a url')).toThrow(HttpException);
});
});
describe('LlmLocalService.listModels', () => {
it('returns named models from /api/tags', async () => {
const fetchFn = mockFetch(async () => ({ ok: true, json: async () => ({ models: [{ name: 'nuextract', size: 100 }, { name: '' }] }) }));
const out = await svc().listModels('http://localhost:11434/v1');
expect(out.models).toEqual([{ name: 'nuextract', size: 100 }]);
expect(fetchFn.mock.calls[0][0]).toBe('http://localhost:11434/api/tags');
});
it('502s when the server is unreachable', async () => {
mockFetch(async () => { throw new Error('ECONNREFUSED'); });
await expect(svc().listModels('http://localhost:11434')).rejects.toThrow(HttpException);
});
});
describe('LlmLocalService.pull', () => {
it('requires a model', async () => {
await expect(svc().pull('http://localhost:11434', '')).rejects.toThrow(HttpException);
});
it('posts to /api/pull and returns the stream body', async () => {
const body = {} as ReadableStream<Uint8Array>;
const fetchFn = mockFetch(async () => ({ ok: true, body }));
const out = await svc().pull('http://localhost:11434/v1', 'nuextract');
expect(out).toBe(body);
expect(fetchFn.mock.calls[0][0]).toBe('http://localhost:11434/api/pull');
const init = fetchFn.mock.calls[0][1];
expect(JSON.parse(init.body)).toEqual({ model: 'nuextract', stream: true });
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { resolveLlmConfig } = vi.hoisted(() => ({ resolveLlmConfig: vi.fn() }));
vi.mock('../../../../src/nest/llm-parse/llm-config.resolver', () => ({ resolveLlmConfig }));
const { createLlmClient, extract } = vi.hoisted(() => {
const extract = vi.fn();
return { createLlmClient: vi.fn(() => ({ extract })), extract };
});
vi.mock('../../../../src/nest/llm-parse/llm-client.factory', () => ({ createLlmClient }));
const { extractText } = vi.hoisted(() => ({ extractText: vi.fn(async () => 'Flight AB123') }));
vi.mock('../../../../src/nest/llm-parse/text-extract', async (orig) => {
const actual = await orig() as Record<string, unknown>;
return { ...actual, extractText };
});
import { LlmParseService } from '../../../../src/nest/llm-parse/llm-parse.service';
const cfg = (over: Record<string, unknown> = {}) => ({ provider: 'openai', model: 'm', multimodal: false, ...over });
const svc = () => new LlmParseService();
const file = (name: string, body = 'Flight AB123') => ({ buffer: Buffer.from(body), originalName: name });
beforeEach(() => {
vi.clearAllMocks();
resolveLlmConfig.mockReturnValue(cfg());
extract.mockResolvedValue([{ '@type': 'FlightReservation' }]);
extractText.mockResolvedValue('Flight AB123');
});
describe('LlmParseService', () => {
it('isAvailable reflects whether a config resolves', () => {
resolveLlmConfig.mockReturnValueOnce(null);
expect(svc().isAvailable(1)).toBe(false);
expect(svc().isAvailable(1)).toBe(true);
});
it('returns a not-configured warning when no config resolves', async () => {
resolveLlmConfig.mockReturnValue(null);
const res = await svc().parse(file('a.txt'), 1);
expect(res.kiItems).toEqual([]);
expect(res.warnings[0]).toMatch(/not configured/i);
expect(extract).not.toHaveBeenCalled();
});
it('sends extracted text for a text-like file', async () => {
const res = await svc().parse(file('a.txt'), 1);
expect(res.kiItems).toEqual([{ '@type': 'FlightReservation' }]);
const input = extract.mock.calls[0][0];
expect(input.text).toBe('Flight AB123');
expect(input.file).toBeUndefined();
});
it('extracts text for a pdf on the OpenAI-compatible/local path (no native bytes)', async () => {
extractText.mockResolvedValue('Hotel X');
await svc().parse(file('a.pdf', '%PDF'), 1);
const input = extract.mock.calls[0][0];
expect(input.text).toBe('Hotel X');
expect(input.file).toBeUndefined();
});
it('sends a pdf as native bytes only for Anthropic', async () => {
resolveLlmConfig.mockReturnValue(cfg({ provider: 'anthropic' }));
await svc().parse(file('a.pdf', '%PDF'), 1);
const input = extract.mock.calls[0][0];
expect(input.file).toEqual({ mimeType: 'application/pdf', data: expect.any(Buffer) });
expect(input.text).toBeUndefined();
expect(extractText).not.toHaveBeenCalled();
});
it('warns when a pdf yields no readable text (e.g. a scan)', async () => {
extractText.mockResolvedValue(' ');
const res = await svc().parse(file('a.pdf', '%PDF'), 1);
expect(res.kiItems).toEqual([]);
expect(res.warnings[0]).toMatch(/no readable text/i);
expect(extract).not.toHaveBeenCalled();
});
it('folds flattened type fields into reservationFor (small-model output)', async () => {
extract.mockResolvedValue([{
'@type': 'FlightReservation',
reservationNumber: 'ABC',
flightNumber: 'EZY1357',
airline: { iataCode: 'EG' },
departureAirport: { iataCode: 'GEG' },
arrivalAirport: { iataCode: 'AMS' },
departureTime: '2026-06-11T10:00:00',
}]);
const res = await svc().parse(file('a.txt'), 1);
const item = res.kiItems[0] as any;
expect(item.reservationNumber).toBe('ABC');
expect(item.reservationFor).toMatchObject({ flightNumber: 'EZY1357', departureAirport: { iataCode: 'GEG' } });
// root-level keys are not duplicated into reservationFor
expect(item.reservationFor.reservationNumber).toBeUndefined();
});
it('leaves already-nested reservationFor untouched', async () => {
extract.mockResolvedValue([{ '@type': 'FlightReservation', reservationFor: { flightNumber: 'X1' } }]);
const res = await svc().parse(file('a.txt'), 1);
expect((res.kiItems[0] as any).reservationFor).toEqual({ flightNumber: 'X1' });
});
it('drops nodes without a string @type and warns', async () => {
extract.mockResolvedValue([{ '@type': 'FlightReservation' }, { foo: 'bar' }]);
const res = await svc().parse(file('a.txt'), 1);
expect(res.kiItems).toEqual([{ '@type': 'FlightReservation' }]);
expect(res.warnings.some(w => /unrecognized/i.test(w))).toBe(true);
});
it('degrades to a warning when the client throws', async () => {
extract.mockRejectedValue(new Error('boom'));
const res = await svc().parse(file('a.txt'), 1);
expect(res.kiItems).toEqual([]);
expect(res.warnings[0]).toMatch(/AI parsing failed/i);
});
});
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { buildSystemPrompt, KI_RESERVATION_JSON_SCHEMA } from '../../../../src/nest/llm-parse/llm-prompt';
import { KI_RESERVATION_TYPES } from '@trek/shared';
describe('llm-prompt', () => {
it('names every recognized @type the mapper supports', () => {
const prompt = buildSystemPrompt();
for (const t of KI_RESERVATION_TYPES) expect(prompt).toContain(t);
});
it('instructs JSON-only output wrapped in reservations', () => {
const prompt = buildSystemPrompt();
expect(prompt).toMatch(/"reservations"/);
expect(prompt.toLowerCase()).toContain('iso 8601');
});
it('exposes a strict-safe object-root JSON schema enumerating the types', () => {
const schema = KI_RESERVATION_JSON_SCHEMA as any;
expect(schema.type).toBe('object');
expect(schema.additionalProperties).toBe(false);
expect(schema.required).toContain('reservations');
const item = schema.properties.reservations.items;
expect(item.properties['@type'].enum).toEqual([...KI_RESERVATION_TYPES]);
expect(item.required).toContain('@type');
});
});
@@ -0,0 +1,168 @@
import { describe, it, expect } from 'vitest';
import {
isNuExtractModel,
buildNuExtractUserText,
nuExtractToKiReservations,
NUEXTRACT_TEMPLATE,
} from '../../../../src/nest/llm-parse/clients/nuextract';
describe('isNuExtractModel', () => {
it('matches NuExtract ids case-insensitively', () => {
expect(isNuExtractModel('hf.co/numind/NuExtract-2.0-2B-GGUF:latest')).toBe(true);
expect(isNuExtractModel('hf.co/numind/NuExtract3-GGUF:Q4_K_M')).toBe(true);
expect(isNuExtractModel('nuextract')).toBe(true);
});
it('does not match generic instruct models', () => {
expect(isNuExtractModel('qwen2.5:7b')).toBe(false);
expect(isNuExtractModel('gpt-4o')).toBe(false);
expect(isNuExtractModel(undefined)).toBe(false);
});
});
describe('buildNuExtractUserText', () => {
it('inlines the template under a "# Template:" header followed by the document', () => {
const text = buildNuExtractUserText('Hotel confirmation 123');
expect(text.startsWith('# Template:\n')).toBe(true);
expect(text).toContain('"verbatim-string"');
expect(text).toContain(JSON.stringify(NUEXTRACT_TEMPLATE, null, 4));
expect(text.endsWith('Hotel confirmation 123')).toBe(true);
});
});
describe('nuExtractToKiReservations', () => {
it('maps a flat flight into a schema.org FlightReservation with from/to airports', () => {
const out = nuExtractToKiReservations({
reservations: [
{
type: 'flight',
name: 'LH 198',
booking_reference: '7XK2QP',
operator: 'Lufthansa',
vehicle_number: 'LH198',
from_name: 'Berlin Brandenburg (BER)',
from_code: 'BER',
to_name: 'Frankfurt am Main (FRA)',
to_code: 'FRA',
departure_time: '2026-07-12T08:35:00',
arrival_time: '2026-07-12T09:50:00',
pickup_location: null,
seat: '14A',
travel_class: 'Economy',
platform: null,
price: 149,
currency: 'EUR',
},
],
});
expect(out).toEqual([
{
'@type': 'FlightReservation',
reservationNumber: '7XK2QP',
seat: '14A',
class: 'Economy',
price: 149,
priceCurrency: 'EUR',
reservationFor: {
flightNumber: 'LH198',
airline: { name: 'Lufthansa' },
departureAirport: { iataCode: 'BER', name: 'Berlin Brandenburg (BER)' },
arrivalAirport: { iataCode: 'FRA', name: 'Frankfurt am Main (FRA)' },
departureTime: '2026-07-12T08:35:00',
arrivalTime: '2026-07-12T09:50:00',
},
},
]);
});
it('maps a hotel with check-in/out at the reservation root', () => {
const [node] = nuExtractToKiReservations({
reservations: [
{
type: 'hotel',
name: 'B&B Hotel Berlin-Airport',
booking_reference: '73365505188894',
address: 'Bertolt-Brecht-Allee 12, 12529 Schoenefeld',
checkin_time: '2026-05-01T15:00:00',
checkout_time: '2026-05-02T12:00:00',
from_name: null,
price: 89,
currency: 'EUR',
},
],
});
expect(node).toEqual({
'@type': 'LodgingReservation',
reservationNumber: '73365505188894',
price: 89,
priceCurrency: 'EUR',
reservationFor: { name: 'B&B Hotel Berlin-Airport', address: 'Bertolt-Brecht-Allee 12, 12529 Schoenefeld' },
checkinTime: '2026-05-01T15:00:00',
checkoutTime: '2026-05-02T12:00:00',
});
});
it('maps a rental car — pickup/return ride the from/to fields, money is parsed', () => {
const [node] = nuExtractToKiReservations([
{
type: 'car',
name: 'VW Golf',
operator: 'SICILY BY CAR',
booking_reference: 'CAR1',
from_name: 'Catania Airport',
to_name: 'Palermo Airport',
departure_time: '2026-12-24T10:00:00',
arrival_time: '2026-12-29T10:00:00',
address: 'Via Roma 1',
price: '€215,50',
currency: '€',
},
]);
expect(node).toEqual({
'@type': 'RentalCarReservation',
reservationNumber: 'CAR1',
price: 215.5,
priceCurrency: 'EUR',
reservationFor: { name: 'VW Golf', rentalCompany: { name: 'SICILY BY CAR' } },
pickupTime: '2026-12-24T10:00:00',
dropoffTime: '2026-12-29T10:00:00',
pickupLocation: { name: 'Catania Airport', address: 'Via Roma 1' },
dropoffLocation: { name: 'Palermo Airport' },
});
});
it('parses localized money strings and currency symbols', () => {
const [de] = nuExtractToKiReservations({ type: 'hotel', name: 'X', price: '1.580,22 €' });
expect(de.price).toBe(1580.22);
expect(de.priceCurrency).toBe('EUR');
const [en] = nuExtractToKiReservations({ type: 'hotel', name: 'Y', price: '$1,580.22' });
expect(en.price).toBe(1580.22);
expect(en.priceCurrency).toBe('USD');
const [plain] = nuExtractToKiReservations({ type: 'hotel', name: 'Z', price: 'EUR 89,00' });
expect(plain.price).toBe(89);
expect(plain.priceCurrency).toBe('EUR');
});
it('falls back to the address instead of dropping a nameless lodging', () => {
const [node] = nuExtractToKiReservations({
type: 'hotel',
booking_reference: 'HMHJ9RTEEK',
address: "Via Aldo Moro, 47 n. 15, Quarto d'Altino",
});
expect(node['@type']).toBe('LodgingReservation');
expect((node.reservationFor as Record<string, unknown>).name).toBe('Via Aldo Moro');
});
it('accepts a bare object and drops unknown types', () => {
expect(nuExtractToKiReservations({ type: 'flight', from_name: 'A', to_name: 'B' })).toEqual([
{
'@type': 'FlightReservation',
reservationFor: {
departureAirport: { name: 'A' },
arrivalAirport: { name: 'B' },
},
},
]);
expect(nuExtractToKiReservations({ reservations: [{ type: 'spaceship' }] })).toEqual([]);
expect(nuExtractToKiReservations(null)).toEqual([]);
});
});
@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from 'vitest';
const { getText } = vi.hoisted(() => ({ getText: vi.fn(async () => ({ text: 'Hotel X — confirmation ABC' })) }));
vi.mock('pdf-parse', () => ({
PDFParse: class {
getText = getText;
destroy = vi.fn(async () => {});
},
}));
import { isTextLike, isPdf, extractText } from '../../../../src/nest/llm-parse/text-extract';
describe('text-extract', () => {
it('classifies text-like and pdf extensions', () => {
expect(isTextLike('a.txt')).toBe(true);
expect(isTextLike('a.html')).toBe(true);
expect(isTextLike('a.eml')).toBe(true);
expect(isTextLike('a.pdf')).toBe(false);
expect(isPdf('a.PDF')).toBe(true);
expect(isPdf('a.txt')).toBe(false);
});
it('decodes plain text', async () => {
expect(await extractText(Buffer.from('hello world'), 'a.txt')).toBe('hello world');
});
it('strips markup from html/eml', async () => {
const html = '<html><style>x{}</style><body><p>Flight AB123</p><script>1</script></body></html>';
const out = await extractText(Buffer.from(html), 'a.html');
expect(out).toContain('Flight AB123');
expect(out).not.toContain('<p>');
expect(out).not.toContain('x{}');
});
it('extracts the embedded text layer from a pdf', async () => {
const out = await extractText(Buffer.from('%PDF-1.4'), 'a.pdf');
expect(out).toBe('Hotel X — confirmation ABC');
expect(getText).toHaveBeenCalled();
});
});
@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest';
import { matchVendorTemplate } from '../../../../src/nest/llm-parse/router/vendor-templates';
import { extractBookingRef, extractTotalPrice } from '../../../../src/nest/llm-parse/router/extraction-router';
// The snippets below mirror the pdf-parse text layer of real confirmation PDFs
// (Expedia hotel receipt, Airbnb booking, a broker rental-car voucher).
const EXPEDIA_HOTEL = `Beleg
Expedia-Reiseplan: 73222406755286
Buchungsdatum: 27. Aug. 2025
Buchungsdetails
Mercure Tokyo Haneda Airport
1 Chome-2-11 Haneda, Ota City, Tokyo, 144-0043 Japan
Anreise: 3. Mai 2026
Abreise: 22. Mai 2026
1 Zimmer x 19 Nächte
Zahlungsdetails
Steuern und Gebühren 1.195,07 €
Gesamtpreis 3.516,13 €
Bezahlt`;
const AIRBNB = `Zwei-Zimmer-Wohnung zwischen Venedig und
Treviso!
Check-in
15:00
Sa., 23. Aug.
Check-out
10:00
Sa., 30. Aug.
Bestätigungs-Code
HMHJ9RTEEK
Adresse
Via Aldo Moro, 47 n. 15, Quarto d'Altino, Venetien 30020, Italien
Bezahlter Betrag
651,86 €`;
const BROKER_RENTAL = `Reservation No.: G72820729
MAIN DRIVER'S NAME: Felix Pakulat
SUPPLIER DETAILS
SICILY BY CAR (V2) Supplier Reference: IT587200464
PICK-UP DETAILS
Venice Marco Polo Airport
Aug 23 2025 13:30
DROP-OFF DETAILS
Venice Marco Polo Airport
Aug 30 2025 12:30
Payment Details
Amount Payable to
Supplier:
(Payable at Pick-up)
EUR 300.21`;
describe('expedia-hotel vendor template', () => {
it('extracts hotel name, address, stay dates, price and Reiseplan number', () => {
const out = matchVendorTemplate(EXPEDIA_HOTEL);
expect(out).toEqual([
{
type: 'hotel',
name: 'Mercure Tokyo Haneda Airport',
booking_reference: '73222406755286',
address: '1 Chome-2-11 Haneda, Ota City, Tokyo, 144-0043 Japan',
checkin_time: '2026-05-03',
checkout_time: '2026-05-22',
price: '3.516,13',
currency: 'EUR',
},
]);
});
it('parses German abbreviated months (e.g. "4. Feb. 2026")', () => {
const bnb = EXPEDIA_HOTEL.replace('Anreise: 3. Mai 2026', 'Anreise: 4. Feb. 2026').replace(
'Abreise: 22. Mai 2026',
'Abreise: 6. Feb. 2026',
);
const out = matchVendorTemplate(bnb);
expect(out?.[0]).toMatchObject({ checkin_time: '2026-02-04', checkout_time: '2026-02-06' });
});
});
describe('broker-rental-voucher vendor template', () => {
it('extracts pickup/return depots, English date-times, price and the customer reservation no.', () => {
const out = matchVendorTemplate(BROKER_RENTAL);
expect(out).toEqual([
{
type: 'car',
operator: 'SICILY BY CAR', // the "(V2)" supplier-version tag is stripped
booking_reference: 'G72820729', // the customer ref, not the supplier reference
from_name: 'Venice Marco Polo Airport',
to_name: 'Venice Marco Polo Airport',
departure_time: '2025-08-23T13:30:00',
arrival_time: '2025-08-30T12:30:00',
price: '300.21',
currency: 'EUR',
},
]);
});
});
describe('non-matching documents', () => {
it('returns null when no template applies', () => {
expect(matchVendorTemplate(AIRBNB)).toBeNull();
expect(matchVendorTemplate('just some unrelated text')).toBeNull();
});
});
describe('broker template — date & price variants', () => {
const VARIANT = `Reservation No.: AB123456
SUPPLIER DETAILS
GREEN MOTION Supplier Reference: XYZ
PICK-UP DETAILS
London Heathrow
Aug 5, 2025 09:00 AM
DROP-OFF DETAILS
London Heathrow
Aug 12, 2025 05:30 PM
Payment Details
Total to pay
150.00 GBP`;
it('handles a comma date, a 12-hour clock and a trailing non-EUR currency', () => {
const out = matchVendorTemplate(VARIANT);
expect(out?.[0]).toMatchObject({
booking_reference: 'AB123456',
departure_time: '2025-08-05T09:00:00', // 09:00 AM
arrival_time: '2025-08-12T17:30:00', // 05:30 PM → 17:30
price: '150.00',
currency: 'GBP', // derived, not hard-coded EUR
});
});
});
describe('extractBookingRef', () => {
it('reads an Airbnb "Bestätigungs-Code"', () => {
expect(extractBookingRef(AIRBNB)).toBe('HMHJ9RTEEK');
});
it('prefers the customer "Reservation No." over a later "Supplier Reference"', () => {
expect(extractBookingRef(BROKER_RENTAL)).toBe('G72820729');
});
it('still reads a classic "Buchungsnummer" / "PNR"', () => {
expect(extractBookingRef('Buchungsnummer: ABC123')).toBe('ABC123');
expect(extractBookingRef('PNR XY7Q9Z')).toBe('XY7Q9Z');
});
it('does not capture a prose word after a bare "Confirmation"/"reference"', () => {
expect(extractBookingRef('Booking Confirmation\n\nThank you for choosing us')).toBeUndefined();
expect(extractBookingRef('For future reference please retain this email')).toBeUndefined();
});
});
describe('extractTotalPrice', () => {
it('reads an Airbnb "Bezahlter Betrag"', () => {
expect(extractTotalPrice(AIRBNB)).toEqual({ price: '651,86', currency: 'EUR' });
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/shared",
"version": "3.1.1",
"version": "3.1.2",
"private": true,
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
"type": "module",
+3
View File
@@ -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} حجز/حجوزات',
+2
View File
@@ -55,6 +55,8 @@ const settings: TranslationStrings = {
'settings.bookingLabels': 'تسميات مسارات الحجوزات',
'settings.bookingLabelsHint': 'عرض أسماء المحطات/المطارات على الخريطة. عند الإيقاف، يتم عرض الرمز فقط.',
'settings.blurBookingCodes': 'إخفاء رموز الحجز',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'تحسين المسار انطلاقًا من مكان الإقامة',
'settings.optimizeFromAccommodationHint':
'عند تحسين يوم ما، يبدأ المسار من الفندق الذي تستيقظ فيه وينتهي عند الفندق الذي تسجّل الوصول إليه في تلك الليلة.',
+3
View File
@@ -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)',
+2
View File
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
'settings.temperature': 'Unidade de temperatura',
'settings.timeFormat': 'Formato de hora',
'settings.blurBookingCodes': 'Ocultar códigos de reserva',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Otimizar rota a partir da hospedagem',
'settings.optimizeFromAccommodationHint':
'Ao otimizar um dia, comece a rota no hotel onde você acorda e termine no hotel em que você faz check-in à noite.',
+3
View File
@@ -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',
+2
View File
@@ -55,6 +55,8 @@ const settings: TranslationStrings = {
'settings.temperature': 'Jednotky teploty',
'settings.timeFormat': 'Formát času',
'settings.blurBookingCodes': 'Skrýt rezervační kódy',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Optimalizovat trasu od ubytování',
'settings.optimizeFromAccommodationHint':
'Při optimalizaci dne začne trasa v hotelu, ve kterém se ráno probudíte, a skončí v hotelu, do kterého se večer ubytujete.',
+3
View File
@@ -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',
+2
View File
@@ -58,6 +58,8 @@ const settings: TranslationStrings = {
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
'settings.blurBookingCodes': 'Buchungscodes verbergen',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Route ab der Unterkunft optimieren',
'settings.optimizeFromAccommodationHint':
'Beim Optimieren eines Tages startet die Route an der Unterkunft, in der du aufwachst, und endet an der, in die du am Abend eincheckst.',
+3
View File
@@ -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',
+2
View File
@@ -60,6 +60,8 @@ const settings: TranslationStrings = {
'settings.mapPoiPillHint':
'Show a category pill on the trip map to find nearby restaurants, hotels and more from OpenStreetMap.',
'settings.blurBookingCodes': 'Blur Booking Codes',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Optimize route from accommodation',
'settings.optimizeFromAccommodationHint':
'When optimizing a day, start the route at the hotel you wake up in and end it at the one you check into that evening.',
+3
View File
@@ -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)',
+2
View File
@@ -57,6 +57,8 @@ const settings: TranslationStrings = {
'settings.temperature': 'Unidad de temperatura',
'settings.timeFormat': 'Formato de hora',
'settings.blurBookingCodes': 'Difuminar códigos de reserva',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Optimizar la ruta desde el alojamiento',
'settings.optimizeFromAccommodationHint':
'Al optimizar un día, comienza la ruta en el hotel donde despiertas y termínala en aquel en el que te registras esa noche.',
+3
View File
@@ -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)',
+2
View File
@@ -58,6 +58,8 @@ const settings: TranslationStrings = {
'settings.temperature': 'Unité de température',
'settings.timeFormat': "Format de l'heure",
'settings.blurBookingCodes': 'Masquer les codes de réservation',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': "Optimiser l'itinéraire depuis l'hébergement",
'settings.optimizeFromAccommodationHint':
"Lors de l'optimisation d'une journée, commencez l'itinéraire à l'hôtel où vous vous réveillez et terminez-le à celui où vous arrivez le soir.",
+3
View File
@@ -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} κράτηση/κρατήσεις εισήχθησαν',
+2
View File
@@ -62,6 +62,8 @@ const settings: TranslationStrings = {
'settings.bookingLabelsHint':
'Εμφάνιση ονομάτων σταθμών / αεροδρομίων στον χάρτη. Όταν είναι απενεργοποιημένο, εμφανίζεται μόνο το εικονίδιο.',
'settings.blurBookingCodes': 'Θόλωμα Κωδικών Κρατήσεων',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Βελτιστοποίηση διαδρομής από το κατάλυμα',
'settings.optimizeFromAccommodationHint':
'Κατά τη βελτιστοποίηση μιας ημέρας, ξεκινήστε τη διαδρομή από το ξενοδοχείο στο οποίο ξυπνάτε και τερματίστε την σε αυτό στο οποίο κάνετε check-in το ίδιο βράδυ.',
+3
View File
@@ -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',
+2
View File
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
'settings.temperature': 'Hőmérséklet egység',
'settings.timeFormat': 'Időformátum',
'settings.blurBookingCodes': 'Foglalási kódok elrejtése',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Útvonal optimalizálása a szállástól',
'settings.optimizeFromAccommodationHint':
'A nap optimalizálásakor az útvonal annál a szállásnál kezdődjön, ahol felébredsz, és annál érjen véget, ahova este bejelentkezel.',
+3
View File
@@ -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',
+2
View File
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
'settings.temperature': 'Satuan Suhu',
'settings.timeFormat': 'Format Waktu',
'settings.blurBookingCodes': 'Sembunyikan Kode Pemesanan',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Optimalkan rute dari akomodasi',
'settings.optimizeFromAccommodationHint':
'Saat mengoptimalkan suatu hari, mulai rute dari hotel tempatmu bangun pagi dan akhiri di hotel tempatmu check-in malam itu.',
+3
View File
@@ -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',
+2
View File
@@ -57,6 +57,8 @@ const settings: TranslationStrings = {
'settings.temperature': 'Unità di Temperatura',
'settings.timeFormat': 'Formato Ora',
'settings.blurBookingCodes': 'Nascondi codici di prenotazione',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': "Ottimizza il percorso dall'alloggio",
'settings.optimizeFromAccommodationHint':
"Quando ottimizzi un giorno, fa iniziare il percorso dall'hotel in cui ti svegli e terminarlo in quello in cui fai il check-in quella sera.",
+3
View File
@@ -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} 件の予約をインポートしました',
+2
View File
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
'settings.bookingLabels': '予約ルートのラベル',
'settings.bookingLabelsHint': '地図に駅・空港名を表示。オフ時はアイコンのみ。',
'settings.blurBookingCodes': '予約コードをぼかす',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': '宿泊先を起点にルートを最適化',
'settings.optimizeFromAccommodationHint':
'その日を最適化する際、朝に目覚める宿泊先を起点にし、その晩にチェックインする宿泊先を終点としてルートを組みます。',
+3
View File
@@ -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}개 예약을 가져왔습니다',
+2
View File
@@ -57,6 +57,8 @@ const settings: TranslationStrings = {
'settings.bookingLabels': '예약 경로 레이블',
'settings.bookingLabelsHint': '지도에 역 / 공항 이름을 표시합니다. 끄면 아이콘만 표시됩니다.',
'settings.blurBookingCodes': '예약 코드 흐리게',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': '숙소 기준으로 경로 최적화',
'settings.optimizeFromAccommodationHint':
'하루 일정을 최적화할 때, 아침에 머무는 숙소에서 경로를 시작하고 그날 저녁에 체크인하는 숙소에서 경로를 끝냅니다.',
+3
View File
@@ -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',
+2
View File
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
'settings.temperature': 'Temperatuureenheid',
'settings.timeFormat': 'Tijdnotatie',
'settings.blurBookingCodes': 'Boekingscodes vervagen',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Route optimaliseren vanaf accommodatie',
'settings.optimizeFromAccommodationHint':
'Begin bij het optimaliseren van een dag de route bij het hotel waar je wakker wordt en eindig bij het hotel waar je die avond incheckt.',
+3
View File
@@ -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',
+2
View File
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
'settings.temperature': 'Jednostka temperatury',
'settings.timeFormat': 'Format czasu',
'settings.blurBookingCodes': 'Rozmyj kody rezerwacji',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Optymalizuj trasę od zakwaterowania',
'settings.optimizeFromAccommodationHint':
'Przy optymalizacji dnia rozpocznij trasę w hotelu, w którym się budzisz, a zakończ ją w tym, do którego się zameldujesz tego wieczoru.',
+3
View File
@@ -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} бронирование(й) импортировано',
+2
View File
@@ -55,6 +55,8 @@ const settings: TranslationStrings = {
'settings.temperature': 'Единица температуры',
'settings.timeFormat': 'Формат времени',
'settings.blurBookingCodes': 'Скрыть коды бронирования',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Оптимизировать маршрут от места проживания',
'settings.optimizeFromAccommodationHint':
'При оптимизации дня маршрут начинается от отеля, в котором вы просыпаетесь, и заканчивается у того, в который вы заселяетесь вечером.',
+3
View File
@@ -130,6 +130,9 @@ const reservations: TranslationStrings = {
'reservations.import.previewHeading': '{count} rezervasyon bulundu',
'reservations.import.previewEmpty': 'Yüklenen dosyalardan hiçbir rezervasyon çıkarılamadı.',
'reservations.import.removeItem': 'Kaldır',
'reservations.import.needsReview': 'Review',
'reservations.import.tryAi': 'Try AI parsing',
'reservations.import.aiParsing': 'Parsing with AI…',
'reservations.import.confirm': '{count} rezervasyonu içe aktar',
'reservations.import.back': 'Geri',
'reservations.import.success': '{count} rezervasyon içe aktarıldı',
+2
View File
@@ -57,6 +57,8 @@ const settings: TranslationStrings = {
'settings.bookingLabels': 'Rezervasyon rota etiketleri',
'settings.bookingLabelsHint': 'Haritada istasyon / havalimanı adlarını göster. Kapalıyken yalnızca simge görünür.',
'settings.blurBookingCodes': 'Rezervasyon Kodlarını Bulanıklaştır',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Rotayı konaklamadan optimize et',
'settings.optimizeFromAccommodationHint':
'Bir günü optimize ederken rotaya o sabah uyandığınız otelden başlayın ve akşam giriş yaptığınız otelde sonlandırın.',
+3
View File
@@ -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} бронювання(нь) імпортовано',
+2
View File
@@ -56,6 +56,8 @@ const settings: TranslationStrings = {
'settings.temperature': 'Одиниця температури',
'settings.timeFormat': 'Формат часу',
'settings.blurBookingCodes': 'Приховати коди бронювання',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': 'Оптимізувати маршрут від житла',
'settings.optimizeFromAccommodationHint':
'Під час оптимізації дня починайте маршрут від готелю, у якому ви прокидаєтеся, і завершуйте його тим, у який ви заселяєтеся ввечері.',
+3
View File
@@ -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} 筆預訂',
+2
View File
@@ -54,6 +54,8 @@ const settings: TranslationStrings = {
'settings.temperature': '溫度單位',
'settings.timeFormat': '時間格式',
'settings.blurBookingCodes': '模糊預訂程式碼',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': '從住宿地點最佳化路線',
'settings.optimizeFromAccommodationHint':
'最佳化某一天的行程時,路線從你早上起床的飯店出發,並在你當晚入住的飯店結束。',
+3
View File
@@ -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} 个预订',
+2
View File
@@ -54,6 +54,8 @@ const settings: TranslationStrings = {
'settings.temperature': '温度单位',
'settings.timeFormat': '时间格式',
'settings.blurBookingCodes': '模糊预订代码',
'settings.aiAlwaysRetry': 'Always retry booking imports with AI',
'settings.aiAlwaysRetryHint': 'When a file cannot be read by the standard parser, automatically retry it with AI.',
'settings.optimizeFromAccommodation': '从住宿地优化路线',
'settings.optimizeFromAccommodationHint': '优化某一天时,路线将从您醒来时所在的酒店出发,并在当晚入住的酒店结束。',
'settings.notifications': '通知',
+1
View File
@@ -26,6 +26,7 @@ export * from './packing/packing.schema';
export * from './todo/todo.schema';
export * from './budget/budget.schema';
export * from './reservation/reservation.schema';
export * from './reservation/ki-reservation.schema';
export * from './airtrail/airtrail.schema';
export * from './day/day.schema';
export * from './assignment/assignment.schema';

Some files were not shown because too many files have changed in this diff Show More